package protocol
// EndpointSet contains per-client service origins. Empty values inherit the
// selected region's evidenced defaults; URLs must be absolute HTTP(S)/WS(S).
type EndpointSet struct {
OAuthBaseURL string
APIBaseURL string
SolutionsBaseURL string
SignalingURL string
}
// Profile returns the verified endpoint defaults for a region. Ring's public
// API and OAuth hosts are established, while only the US Solutions bootstrap
// origin is evidenced. EU and FE callers must provide SolutionsBaseURL.
func Profile(region string) EndpointSet {
profile := EndpointSet{
OAuthBaseURL: OAuthBaseURL,
APIBaseURL: APIBaseURL,
SolutionsBaseURL: "",
SignalingURL: SignalingURL,
}
if region == "US" {
profile.SolutionsBaseURL = USSolutionsBaseURL
}
return profile
}
// Code generated by tools/protocols/generate_protocol_constants.mjs. DO NOT EDIT.
package protocol
const (
SignalingChannel = "serverEnvelope"
AccountEventsChannel = "accountEvent"
signalingProtocol = "wss"
signalingHost = "api.prod.signalling.ring.devices.a2z.com"
signalingPort = "443"
signalingPath = "/ws"
accountEventsProtocol = "wss"
accountEventsHost = "api.ring.com"
accountEventsPort = "443"
accountEventsPath = "/clients_api/ws"
signalingQueryApiVersionKey = "api_version"
signalingApiVersion = "4.0"
signalingQueryAuthTypeKey = "auth_type"
signalingAuthType = "ring_solutions"
signalingQueryClientIdKey = "client_id"
signalingClientIdPrefix = "ring_site-"
signalingQueryTokenKey = "token"
MethodActivateSession = "activate_session"
MethodCameraOptions = "camera_options"
MethodCameraStarted = "camera_started"
MethodClose = "close"
MethodICE = "ice"
MethodLiveView = "live_view"
MethodMicEnable = "mic_enable"
MethodNotification = "notification"
MethodPing = "ping"
MethodPlayback = "playback"
MethodPong = "pong"
MethodPushEvent = "push_event"
MethodPushHeartbeat = "push_heartbeat"
MethodPushSubscribe = "push_subscribe"
MethodPushSubscriptionAck = "push_subscription_ack"
MethodPushUnsubscribe = "push_unsubscribe"
MethodRPC = "rpc"
MethodSDP = "sdp"
MethodSessionCreated = "session_created"
MethodStreamOptions = "stream_options"
RPCPanStep = "PTZ.Pan.Step"
RPCPanContinuous = "PTZ.Pan.Continuous"
RPCTiltStep = "PTZ.Tilt.Step"
RPCTiltContinuous = "PTZ.Tilt.Continuous"
RPCPanHalted = "PTZ.Pan.Halted"
JSONRPCVersion = "2.0"
PTZVersion = 1
PTZMaxSpeed = 1
PanLeft = "LEFT"
PanRight = "RIGHT"
TiltUp = "UP"
TiltDown = "DOWN"
SDPTypeOffer = "offer"
SDPTypeAnswer = "answer"
PlaybackOfferTypeCloud = "cloud"
PlaybackCloseReasonClientClosed = "client_closed"
PlaybackEntryPointTimeline = "timeline"
SubscriptionStatusOK = "ok"
SDPSendRecv = "sendrecv"
SDPSendOnly = "sendonly"
SDPRecvOnly = "recvonly"
SDPInactive = "inactive"
FieldMethod = "method"
FieldDialogID = "dialog_id"
FieldRIID = "riid"
FieldBody = "body"
FieldDeviceID = "doorbot_id"
FieldSessionID = "session_id"
FieldIce = "ice"
FieldMID = "mid"
FieldMLineIndex = "mlineindex"
FieldIsOK = "is_ok"
FieldText = "text"
FieldNotificationScope = "notification_scope"
FieldNotificationType = "notification_type"
FieldPayload = "payload"
FieldSubscriptionID = "subscription_id"
FieldStatus = "status"
FieldSessionInfo = "session_info"
FieldSDP = "sdp"
FieldStreamOptions = "stream_options"
FieldType = "type"
FieldEntryPoint = "entry_point"
FieldRequestedNotifications = "requested_notifications"
FieldJSONRPC = "jsonrpc"
FieldRPCID = "id"
FieldParams = "params"
FieldResult = "result"
FieldError = "error"
FieldCode = "code"
FieldReason = "reason"
FieldPingInterval = "ping_interval"
FieldEnabled = "enabled"
FieldAudioEnabled = "audio_enabled"
FieldVideoEnabled = "video_enabled"
FieldDirection = "direction"
FieldSpeed = "speed"
FieldSessionIDRPC = "sessionId"
FieldTimestamp = "timestamp"
FieldVersion = "version"
FieldCommand = "command"
)
func signalingQueryKeys() []string {
return []string{signalingQueryApiVersionKey, signalingQueryAuthTypeKey, signalingQueryClientIdKey, signalingQueryTokenKey}
}
package protocol
import (
"net/url"
"strings"
)
// ValidateWebSocketURL checks a default endpoint against its AsyncAPI channel.
func ValidateWebSocketURL(channel, rawURL string) error {
parsed, err := parseWebSocketURL(rawURL)
if err != nil {
return err
}
switch channel {
case SignalingChannel:
if parsed.Scheme != signalingProtocol || parsed.Hostname() != signalingHost ||
!webSocketPortMatches(parsed.Port(), signalingPort) || parsed.EscapedPath() != signalingPath {
return webSocketURLError{reason: reasonSignalingEndpoint, cause: nil}
}
return validateSignalingQuery(parsed.Query())
case AccountEventsChannel:
if parsed.Scheme != accountEventsProtocol || parsed.Hostname() != accountEventsHost ||
!webSocketPortMatches(parsed.Port(), accountEventsPort) || parsed.EscapedPath() != accountEventsPath ||
parsed.RawQuery != "" {
return webSocketURLError{reason: reasonEventEndpoint, cause: nil}
}
return nil
default:
return webSocketURLError{reason: reasonUnknownChannel, cause: nil}
}
}
func webSocketPortMatches(actual, expected string) bool {
return actual == "" || actual == expected
}
// ValidateWebSocketOverride checks a caller-supplied transport URL.
func ValidateWebSocketOverride(rawURL string) error {
_, err := parseWebSocketURL(rawURL)
return err
}
func parseWebSocketURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, webSocketURLError{reason: reasonInvalidURL, cause: err}
}
if parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" ||
(parsed.Scheme != "ws" && parsed.Scheme != "wss") {
return nil, webSocketURLError{reason: reasonInvalidURL, cause: nil}
}
return parsed, nil
}
func validateSignalingQuery(values url.Values) error {
queryKeys := signalingQueryKeys()
if len(values) != len(queryKeys) {
return webSocketURLError{reason: reasonUnexpectedQuery, cause: nil}
}
for _, name := range queryKeys {
if len(values[name]) != 1 {
return webSocketURLError{reason: reasonMissingQueryValue, cause: nil}
}
}
clientID := values.Get(signalingQueryClientIdKey)
if values.Get(signalingQueryApiVersionKey) != signalingApiVersion ||
values.Get(signalingQueryAuthTypeKey) != signalingAuthType ||
!strings.HasPrefix(clientID, signalingClientIdPrefix) ||
len(clientID) <= len(signalingClientIdPrefix) || values.Get(signalingQueryTokenKey) == "" {
return webSocketURLError{reason: reasonSignalingQuery, cause: nil}
}
return nil
}
package protocol
const (
reasonSignalingEndpoint = "signaling URL differs from the AsyncAPI server and channel"
reasonEventEndpoint = "account event URL differs from the AsyncAPI server and channel"
reasonUnknownChannel = "unknown AsyncAPI WebSocket channel"
reasonInvalidURL = "WebSocket URL must have a ws or wss origin without userinfo or fragment"
reasonUnexpectedQuery = "signaling URL has unexpected query parameters"
reasonMissingQueryValue = "signaling URL requires one value per query key"
reasonSignalingQuery = "signaling URL query differs from the AsyncAPI binding"
)
type webSocketURLError struct {
reason string
cause error
}
func (e webSocketURLError) Error() string { return e.reason }
func (e webSocketURLError) Unwrap() error { return e.cause }
package requestauth
import "context"
// Account is the authorization snapshot for one operation. It is never stored
// on a shared transport client.
type Account struct {
AccessToken string
HardwareID string
}
type contextKey struct{}
func WithAccount(ctx context.Context, account Account) context.Context {
return context.WithValue(ctx, contextKey{}, account)
}
func FromContext(ctx context.Context) (Account, bool) {
account, ok := ctx.Value(contextKey{}).(Account)
return account, ok
}
package ringerrors
import (
"errors"
"fmt"
"net/http"
)
// AuthenticationError represents an authentication failure.
type AuthenticationError struct {
Message string
Status int
}
// NewAuthenticationError creates a new AuthenticationError.
func NewAuthenticationError(message string, status int) *AuthenticationError {
return &AuthenticationError{
Message: message,
Status: status,
}
}
func (e *AuthenticationError) Error() string {
if e.Message != "" {
return "authentication error: " + e.Message
}
return fmt.Sprintf("authentication error (status: %d)", e.Status)
}
// IsAuthenticationError checks if an error is an AuthenticationError.
func IsAuthenticationError(err error) bool {
var target *AuthenticationError
return errors.As(err, &target) && target != nil
}
// ConnectionError represents a connection failure.
type ConnectionError struct {
Message string
Err error
}
// NewConnectionError creates a new ConnectionError.
func NewConnectionError(message string, err error) *ConnectionError {
return &ConnectionError{
Message: message,
Err: err,
}
}
func (e *ConnectionError) Error() string {
if e.Err != nil {
return fmt.Sprintf("connection error: %s: %v", e.Message, e.Err)
}
return "connection error: " + e.Message
}
func (e *ConnectionError) Unwrap() error {
return e.Err
}
// IsConnectionError checks if an error is a ConnectionError.
func IsConnectionError(err error) bool {
var target *ConnectionError
return errors.As(err, &target) && target != nil
}
// NetworkError represents a network-level error.
type NetworkError struct {
Message string
Err error
}
// NewNetworkError creates a new NetworkError.
func NewNetworkError(message string, err error) *NetworkError {
return &NetworkError{
Message: message,
Err: err,
}
}
func (e *NetworkError) Error() string {
if e.Err != nil {
return fmt.Sprintf("network error: %s: %v", e.Message, e.Err)
}
return "network error: " + e.Message
}
func (e *NetworkError) Unwrap() error {
return e.Err
}
// IsNetworkError checks if an error is a NetworkError.
func IsNetworkError(err error) bool {
var target *NetworkError
return errors.As(err, &target) && target != nil
}
// TokenError represents a token retrieval or validation error.
type TokenError struct {
Message string
Err error
}
// NewTokenError creates a new TokenError.
func NewTokenError(message string, err error) *TokenError {
return &TokenError{
Message: message,
Err: err,
}
}
func (e *TokenError) Error() string {
if e.Err != nil {
return fmt.Sprintf("token error: %s: %v", e.Message, e.Err)
}
return "token error: " + e.Message
}
func (e *TokenError) Unwrap() error {
return e.Err
}
// IsTokenError checks if an error is a TokenError.
func IsTokenError(err error) bool {
var target *TokenError
return errors.As(err, &target) && target != nil
}
// BadRequestError represents a bad request error.
type BadRequestError struct {
Message string
Err error
}
// NewBadRequestError creates a new BadRequestError.
func NewBadRequestError(message string, err error) *BadRequestError {
return &BadRequestError{
Message: message,
Err: err,
}
}
func (e *BadRequestError) Error() string {
if e.Err != nil {
return fmt.Sprintf("bad request error: %s: %v", e.Message, e.Err)
}
return "bad request error: " + e.Message
}
func (e *BadRequestError) Unwrap() error {
return e.Err
}
// IsBadRequestError checks if an error is a BadRequestError.
func IsBadRequestError(err error) bool {
var target *BadRequestError
return errors.As(err, &target) && target != nil
}
// UnauthorizedError represents an unauthorized error.
type UnauthorizedError struct {
Message string
Err error
}
// NewUnauthorizedError creates a new UnauthorizedError.
func NewUnauthorizedError(message string, err error) *UnauthorizedError {
return &UnauthorizedError{
Message: message,
Err: err,
}
}
func (e *UnauthorizedError) Error() string {
if e.Err != nil {
return fmt.Sprintf("unauthorized error: %s: %v", e.Message, e.Err)
}
return "unauthorized error: " + e.Message
}
func (e *UnauthorizedError) Unwrap() error {
return e.Err
}
// IsUnauthorizedError checks if an error is a UnauthorizedError.
func IsUnauthorizedError(err error) bool {
var target *UnauthorizedError
return errors.As(err, &target) && target != nil
}
// NotFoundError represents a not found error.
type NotFoundError struct {
Message string
Err error
}
// NewNotFoundError creates a new NotFoundError.
func NewNotFoundError(message string, err error) *NotFoundError {
return &NotFoundError{
Message: message,
Err: err,
}
}
func (e *NotFoundError) Error() string {
if e.Message != "" {
return "not found error: " + e.Message
}
return "not found"
}
func (e *NotFoundError) Unwrap() error {
return e.Err
}
// IsNotFoundError checks if an error is a NotFoundError.
func IsNotFoundError(err error) bool {
var target *NotFoundError
return errors.As(err, &target) && target != nil
}
// InternalServerError represents an internal server error.
type InternalServerError struct {
Message string
Err error
}
// NewInternalServerError creates a new InternalServerError.
func NewInternalServerError(message string, err error) *InternalServerError {
return &InternalServerError{
Message: message,
Err: err,
}
}
func (e *InternalServerError) Error() string {
return fmt.Sprintf("internal server error: %s: %v", e.Message, e.Err)
}
func (e *InternalServerError) Unwrap() error {
return e.Err
}
// IsInternalServerError checks if an error is a InternalServerError.
func IsInternalServerError(err error) bool {
var target *InternalServerError
return errors.As(err, &target) && target != nil
}
// HTTPError represents an HTTP-level error with status code.
type HTTPError struct {
StatusCode int
Status string
Body string
Message string
}
// NewHTTPError creates a new HTTPError from an HTTP response.
func NewHTTPError(resp *http.Response, body string) *HTTPError {
return &HTTPError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: body,
Message: "",
}
}
// Error excludes the raw response body, which can contain credentials or personal data.
// Callers may inspect Body explicitly when handling the error securely.
func (e *HTTPError) Error() string {
if e.Message != "" {
return fmt.Sprintf("HTTP error %d (%s): %s", e.StatusCode, e.Status, e.Message)
}
return fmt.Sprintf("HTTP error %d (%s)", e.StatusCode, e.Status)
}
// IsHTTPError checks if an error is an HTTPError.
func IsHTTPError(err error) bool {
var target *HTTPError
return errors.As(err, &target) && target != nil
}
// IsHTTPStatusCode checks if an error is an HTTPError with a specific status code.
func IsHTTPStatusCode(err error, code int) bool {
var httpErr *HTTPError
if !errors.As(err, &httpErr) || httpErr == nil {
return false
}
return httpErr.StatusCode == code
}
// ClosedError represents an error when trying to use a closed connection.
type ClosedError struct {
Message string
Err error
}
// NewClosedError creates a new ClosedError.
func NewClosedError(message string, cause ...error) *ClosedError {
var err error
if len(cause) > 0 {
err = cause[0]
}
return &ClosedError{
Message: message,
Err: err,
}
}
func (e *ClosedError) Unwrap() error { return e.Err }
func (e *ClosedError) Error() string {
if e.Message != "" {
return "connection closed: " + e.Message
}
return "connection closed"
}
// IsClosedError checks if an error is a ClosedError.
func IsClosedError(err error) bool {
var target *ClosedError
return errors.As(err, &target) && target != nil
}
// Requires2FAError represents an error when 2FA is required.
type Requires2FAError struct {
Message string
}
// NewRequires2FAError creates a new Requires2FAError.
func NewRequires2FAError(message string) *Requires2FAError {
return &Requires2FAError{
Message: message,
}
}
type RateLimitError struct {
Message string
Err error
}
// NewRateLimitError creates a new RateLimitError.
func NewRateLimitError(message string) *RateLimitError {
return &RateLimitError{
Message: message,
Err: nil,
}
}
func (e *RateLimitError) Unwrap() error { return e.Err }
func (e *RateLimitError) Error() string {
if e.Message != "" {
return "rate limit error: " + e.Message
}
return "rate limit error"
}
func (e *Requires2FAError) Error() string {
if e.Message != "" {
return "2FA required: " + e.Message
}
return "2FA required"
}
// IsRequires2FAError checks if an error is a Requires2FAError.
func IsRequires2FAError(err error) bool {
var target *Requires2FAError
return errors.As(err, &target) && target != nil
}
// ClassifyHTTPError preserves the status-bearing HTTPError while also exposing
// the most specific public error family for common response codes.
func ClassifyHTTPError(resp *http.Response, body string) error {
httpErr := NewHTTPError(resp, body)
switch resp.StatusCode {
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return NewBadRequestError("request rejected", httpErr)
case http.StatusUnauthorized, http.StatusForbidden:
return NewUnauthorizedError("request not authorized", httpErr)
case http.StatusNotFound:
return NewNotFoundError("resource not found", httpErr)
case http.StatusTooManyRequests:
return &RateLimitError{Message: "request rate limited", Err: httpErr}
default:
if resp.StatusCode >= http.StatusInternalServerError {
return NewInternalServerError("server request failed", httpErr)
}
return httpErr
}
}
func IsRateLimitError(err error) bool {
var target *RateLimitError
return errors.As(err, &target) && target != nil
}
package signaling
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
)
type terminalError string
func (r terminalError) Error() string { return string(r) }
type sessionContextError struct {
operation string
cause error
}
func (failure sessionContextError) Error() string {
return failure.operation + ": " + failure.cause.Error()
}
func (failure sessionContextError) Unwrap() error { return failure.cause }
func wrapSessionContextError(operation string, cause error) error {
if cause == nil {
return nil
}
return sessionContextError{operation: operation, cause: cause}
}
var (
ErrClosed error = terminalError("session closed")
ErrExpired error = terminalError("session reached maximum age")
ErrHeartbeat error = terminalError("session heartbeat timed out")
ErrBackpressure error = terminalError("session event queue full")
)
// Clock allows deterministic deadlines without waiting real session lifetimes.
type Clock interface {
Now() time.Time
After(duration time.Duration) <-chan time.Time
}
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
func (RealClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
type Message struct {
Method string
DialogID string
RIID string
Body json.RawMessage
}
func (message Message) MarshalJSON() ([]byte, error) {
method, exists := generatedsignaling.ValuesToAnonymousSchema_1[message.Method]
if !exists {
return nil, ringerrors.NewBadRequestError("unknown signaling method", nil)
}
var body map[string]interface{}
if len(message.Body) > 0 {
err := json.Unmarshal(message.Body, &body)
if err != nil {
return nil, ringerrors.NewBadRequestError("invalid signaling message body", err)
}
}
envelope := generatedsignaling.SignalingInboundDiscriminator{
Method: &method,
DialogId: message.DialogID,
Riid: message.RIID,
Body: body,
AdditionalProperties: nil,
}
encoded, err := json.Marshal(envelope)
if err != nil {
return nil, ringerrors.NewInternalServerError("encode signaling message", err)
}
return encoded, nil
}
func (message *Message) UnmarshalJSON(encoded []byte) error {
var envelope generatedsignaling.SignalingInboundDiscriminator
err := json.Unmarshal(encoded, &envelope)
if err != nil {
return ringerrors.NewConnectionError("invalid signaling envelope", err)
}
if envelope.Method == nil {
return ringerrors.NewConnectionError("signaling envelope has no method", nil)
}
var fields map[string]json.RawMessage
err = json.Unmarshal(encoded, &fields)
if err != nil {
return ringerrors.NewConnectionError("invalid signaling envelope fields", err)
}
var method string
err = json.Unmarshal(fields[protocol.FieldMethod], &method)
if err != nil {
return ringerrors.NewConnectionError("invalid signaling method", err)
}
body, exists := fields[protocol.FieldBody]
if !exists {
return ringerrors.NewConnectionError("signaling envelope has no body", nil)
}
*message = Message{
Method: method,
DialogID: envelope.DialogId,
RIID: envelope.Riid,
Body: append(json.RawMessage(nil), body...),
}
return nil
}
type RPCError struct {
Code int
Message string
}
func (e *RPCError) Error() string { return fmt.Sprintf("session RPC error %d", e.Code) }
type rpcReply struct {
result json.RawMessage
err error
}
// Session owns an activated device's routing and RPC state. Negotiation and the
// single socket reader belong to the connection. Send must honor its context.
type Session struct {
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
done chan struct{}
workers sync.WaitGroup
clock Clock
send func(context.Context, Message) error
deviceID int64
dialogID, signalID, controlID string
lastPong time.Time
expiresAt time.Time
terminal error
closed bool
sequence uint64
pending map[string]chan rpcReply
events chan Message
}
type SessionConfig struct {
DeviceID int64
DialogID, SignalID, ControlID string
Heartbeat, MaxAge time.Duration
Clock Clock
Send func(context.Context, Message) error
}
func NewSession(ctx context.Context, config SessionConfig) (*Session, error) {
if config.DeviceID <= 0 ||
config.DialogID == "" ||
config.SignalID == "" ||
config.ControlID == "" ||
config.SignalID == config.ControlID ||
config.Send == nil {
return nil, ringerrors.NewBadRequestError("invalid session configuration", nil)
}
if config.Heartbeat <= 0 || config.Heartbeat > MaxHeartbeatInterval {
return nil, ringerrors.NewBadRequestError("invalid heartbeat interval", nil)
}
if config.MaxAge == 0 {
config.MaxAge = MaxSessionAge
}
if config.MaxAge <= 0 || config.MaxAge > MaxSessionAge {
return nil, ringerrors.NewBadRequestError("invalid maximum session age", nil)
}
err := ctx.Err()
if err != nil {
return nil, wrapSessionContextError("create signaling session", err)
}
if config.Clock == nil {
config.Clock = RealClock{}
}
child, cancel := context.WithCancel(ctx)
session := &Session{
ctx: child,
cancel: cancel,
done: make(chan struct{}),
clock: config.Clock,
send: config.Send,
deviceID: config.DeviceID,
dialogID: config.DialogID,
signalID: config.SignalID,
controlID: config.ControlID,
lastPong: config.Clock.Now(),
expiresAt: config.Clock.Now().Add(config.MaxAge),
pending: make(map[string]chan rpcReply),
events: make(chan Message, EventQueueCapacity),
}
// Create timers before returning so fake-clock advances cannot race startup.
expiry := config.Clock.After(config.MaxAge)
tick := config.Clock.After(config.Heartbeat)
session.workers.Add(2)
go func() {
defer session.workers.Done()
select {
case <-session.ctx.Done():
session.finish(session.ctx.Err())
case <-expiry:
session.finish(ErrExpired)
}
}()
go func() {
defer session.workers.Done()
for {
select {
case <-session.ctx.Done():
return
case <-tick:
session.mu.Lock()
age := session.clock.Now().Sub(session.lastPong)
session.mu.Unlock()
if age >= MissedHeartbeatIntervals*config.Heartbeat {
session.finish(ErrHeartbeat)
return
}
// Arm the next tick before sending to make the observable send a barrier.
tick = session.clock.After(config.Heartbeat)
err := session.Send(session.ctx, protocol.MethodPing, nil)
if err != nil {
session.finish(err)
return
}
}
}
}()
return session, nil
}
func (s *Session) Wait(ctx context.Context) error {
select {
case <-s.done:
s.mu.Lock()
defer s.mu.Unlock()
return s.terminal
case <-ctx.Done():
return wrapSessionContextError("wait for signaling session", ctx.Err())
}
}
func (s *Session) Close() error {
s.finish(ErrClosed)
s.workers.Wait()
return nil
}
// Fail terminates from the connection reader without joining any worker.
func (s *Session) Fail(err error) {
if err == nil {
err = ErrClosed
}
s.finish(err)
}
func (s *Session) Pending() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pending)
}
func (s *Session) Send(ctx context.Context, method string, body json.RawMessage) error {
{
err := ctx.Err()
if err != nil {
return wrapSessionContextError("send signaling message", err)
}
}
s.mu.Lock()
closed := s.closed
terminal := s.terminal
s.mu.Unlock()
if closed {
return terminal
}
if !s.clock.Now().Before(s.expiresAt) {
s.finish(ErrExpired)
return ErrExpired
}
if len(body) == 0 {
encoded, err := json.Marshal(generatedsignaling.SessionBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
AdditionalProperties: nil,
})
if err != nil {
return ringerrors.NewInternalServerError("invalid session payload", err)
}
body = encoded
}
writeCtx, cancel := context.WithCancel(ctx)
defer cancel()
// The session context must also cancel a write when the caller context is still active.
//nolint:contextcheck // AfterFunc explicitly links the inherited write context to the session lifetime.
stop := context.AfterFunc(s.ctx, cancel)
defer stop()
return s.send(writeCtx, Message{
Method: method,
DialogID: s.dialogID,
RIID: "",
Body: body,
})
}
// Call registers correlation before sending. A result acknowledges the command;
// it does not claim physical motion completed. Calls are never retried.
func (s *Session) Call(
ctx context.Context,
method string,
direction generatedsignaling.PtzDirection,
speed *float64,
) (json.RawMessage, error) {
err := ctx.Err()
if err != nil {
return nil, wrapSessionContextError("start signaling RPC", err)
}
commandMethod, continuousWireMethod, continuousMethod, err := validatePTZCommand(method, direction, speed)
if err != nil {
return nil, err
}
// Every RPC has a bounded result wait even when the caller provides no
// deadline. The injected clock also cancels a queued or blocked write.
callCtx, cancel := context.WithCancelCause(ctx)
timeout := s.clock.After(RPCResponseTimeout)
finished, stopped := make(chan struct{}), make(chan struct{})
go func() {
defer close(stopped)
select {
case <-finished:
case <-s.done:
cancel(s.terminalCause())
case <-callCtx.Done():
case <-timeout:
cancel(context.DeadlineExceeded)
}
}()
defer func() { close(finished); cancel(nil); <-stopped }()
ctx = callCtx
s.mu.Lock()
if s.closed {
err := s.terminal
s.mu.Unlock()
return nil, err
}
s.sequence++
id := fmt.Sprintf("%s-%d", s.controlID, s.sequence)
ch := make(chan rpcReply, 1)
s.pending[id] = ch
s.mu.Unlock()
defer func() { s.mu.Lock(); delete(s.pending, id); s.mu.Unlock() }()
encodedBody, err := marshalPTZPayload(
commandMethod,
continuousWireMethod,
continuousMethod,
direction,
speed,
int(s.deviceID),
s.signalID,
s.controlID,
id,
int(s.clock.Now().UnixMilli()),
)
if err != nil {
return nil, err
}
err = s.Send(ctx, protocol.MethodRPC, encodedBody)
if err != nil {
cause := context.Cause(callCtx)
if cause != nil {
return nil, wrapSessionContextError("send signaling RPC", cause)
}
// The session may cancel the write before the call's cancellation
// watcher runs. Preserve its terminal cause rather than leaking the
// internal context.Canceled used only to interrupt the transport.
select {
case <-s.done:
return nil, s.terminalCause()
default:
}
return nil, err
}
select {
case reply := <-ch:
return reply.result, reply.err
case <-ctx.Done():
return nil, wrapSessionContextError("wait for signaling RPC reply", context.Cause(callCtx))
case <-s.done:
return nil, s.terminalCause()
}
}
func validatePTZCommand(
method string,
direction generatedsignaling.PtzDirection,
speed *float64,
) (generatedsignaling.PtzCommandMethod, generatedsignaling.AnonymousSchema_199, bool, error) {
commandMethod, exists := generatedsignaling.ValuesToPtzCommandMethod[method]
if !exists || direction.Value() == nil {
return 0, 0, false, ringerrors.NewBadRequestError("invalid PTZ command method or direction", nil)
}
continuous := commandMethod == generatedsignaling.PtzCommandMethodPtzDotPanDotContinuous ||
commandMethod == generatedsignaling.PtzCommandMethodPtzDotTiltDotContinuous
if continuous != (speed != nil) {
return 0, 0, false, ringerrors.NewBadRequestError("PTZ speed must be supplied only for continuous commands", nil)
}
continuousMethod, exists := generatedsignaling.ValuesToAnonymousSchema_199[method]
if continuous && !exists {
return 0, 0, false, ringerrors.NewBadRequestError("invalid continuous PTZ command method", nil)
}
return commandMethod, continuousMethod, continuous, nil
}
func marshalPTZPayload(
commandMethod generatedsignaling.PtzCommandMethod,
continuousMethod generatedsignaling.AnonymousSchema_199,
continuous bool,
direction generatedsignaling.PtzDirection,
speed *float64,
deviceID int,
signalID, controlID, id string,
timestamp int,
) (json.RawMessage, error) {
var payload any
if continuous {
params := generatedsignaling.PtzContinuousParams{
SessionId: controlID,
Timestamp: timestamp,
Version: protocol.PTZVersion,
Direction: &direction,
Speed: *speed,
Reason: "",
AdditionalProperties: nil,
}
command := generatedsignaling.PtzContinuousWireCommand{
Jsonrpc: protocol.JSONRPCVersion,
Id: id,
Method: &continuousMethod,
Params: ¶ms,
AdditionalProperties: nil,
}
payload = generatedsignaling.PtzContinuousCommandBody{
DoorbotId: deviceID,
SessionId: signalID,
Command: &command,
AdditionalProperties: nil,
}
} else {
params := generatedsignaling.PtzWireParams{
SessionId: controlID,
Timestamp: timestamp,
Version: protocol.PTZVersion,
Direction: &direction,
Speed: 0,
Reason: "",
AdditionalProperties: nil,
}
command := generatedsignaling.PtzWireCommand{
Jsonrpc: protocol.JSONRPCVersion,
Id: id,
Method: &commandMethod,
Params: ¶ms,
AdditionalProperties: nil,
}
payload = generatedsignaling.PtzCommandBody{
DoorbotId: deviceID,
SessionId: signalID,
Command: &command,
AdditionalProperties: nil,
}
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, ringerrors.NewInternalServerError("invalid PTZ payload", err)
}
return encoded, nil
}
// Handle is called by the connection's sole reader. Wrong-session messages are
// ignored; they must not satisfy a pending request or renew its heartbeat.
func (s *Session) Handle(message Message) error {
if message.DialogID != s.dialogID {
return nil
}
var identity generatedsignaling.SessionBody
err := json.Unmarshal(message.Body, &identity)
if err != nil {
return ringerrors.NewConnectionError("invalid session body", err)
}
if int64(identity.DoorbotId) != s.deviceID || identity.SessionId != s.signalID {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return s.terminal
}
if message.Method == protocol.MethodPong {
s.lastPong = s.clock.Now()
return nil
}
if message.Method == protocol.MethodRPC {
handled, err := s.handleRPCFrame(message.Body)
if err != nil {
return err
}
if handled {
return nil
}
}
select {
case s.events <- message:
return nil
default:
return ErrBackpressure
}
}
func (s *Session) Receive(ctx context.Context) (Message, error) {
select {
case m := <-s.events:
return m, nil
case <-s.done:
return Message{}, s.terminalCause()
case <-ctx.Done():
return Message{}, wrapSessionContextError("receive signaling event", ctx.Err())
}
}
func (s *Session) handleRPCFrame(body json.RawMessage) (bool, error) {
var rpcBody generatedsignaling.ServerRpcBody
err := json.Unmarshal(body, &rpcBody)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC body", err)
}
var rpcFields map[string]json.RawMessage
err = json.Unmarshal(body, &rpcFields)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC body", err)
}
commandBody := rpcFields[protocol.FieldCommand]
if rpcBody.Command == nil || len(commandBody) == 0 {
return false, ringerrors.NewConnectionError("invalid RPC body", nil)
}
return s.handleRPC(commandBody)
}
func (s *Session) finish(err error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.terminal = err
pending := s.pending
s.pending = make(map[string]chan rpcReply)
s.mu.Unlock()
for _, ch := range pending {
select {
case ch <- rpcReply{result: nil, err: err}:
default:
}
}
close(s.done)
// Publish the terminal cause before canceling an in-flight transport write.
// Otherwise that write can return context.Canceled before Call can observe
// the actual session failure through done.
s.cancel()
}
func (s *Session) terminalCause() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.terminal
}
func (s *Session) handleRPC(body json.RawMessage) (bool, error) {
var command generatedsignaling.ServerRpcCommand
err := json.Unmarshal(body, &command)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC envelope (command cannot be decoded)", err)
}
if command.Jsonrpc == "" {
var wrapper generatedsignaling.RpcCommandWrapper
err = json.Unmarshal(body, &wrapper)
if err != nil || wrapper.Message == nil {
return false, ringerrors.NewConnectionError("invalid RPC wrapper", err)
}
body, err = json.Marshal(wrapper.Message)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC wrapper message", err)
}
err = json.Unmarshal(body, &command)
if err != nil {
return false, ringerrors.NewConnectionError("invalid wrapped RPC envelope", err)
}
}
if command.Jsonrpc != protocol.JSONRPCVersion {
return false, ringerrors.NewConnectionError(
"invalid RPC envelope (unsupported or missing jsonrpc version)",
nil,
)
}
if command.Method != "" {
return false, nil
}
if (command.Result == nil) == (command.Error == nil) {
return false, ringerrors.NewConnectionError("RPC reply must have exactly one result or error", nil)
}
if command.Error == nil {
var resultFields map[string]json.RawMessage
err := json.Unmarshal(body, &resultFields)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC result", err)
}
var result generatedsignaling.RpcResultValue
err = json.Unmarshal(resultFields[protocol.FieldResult], &result)
if err != nil {
return false, ringerrors.NewConnectionError("invalid RPC result identity", err)
}
if result.SessionId == "" {
return false, ringerrors.NewConnectionError("invalid RPC result identity", nil)
}
if result.SessionId != s.controlID {
return true, nil
}
}
if ch, ok := s.pending[command.Id]; ok {
var result json.RawMessage
if command.Error == nil {
var resultFields map[string]json.RawMessage
err = json.Unmarshal(body, &resultFields)
if err == nil {
result = append(json.RawMessage(nil), resultFields[protocol.FieldResult]...)
}
}
reply := rpcReply{result: result, err: nil}
if command.Error != nil {
reply.err = &RPCError{Code: command.Error.Code, Message: command.Error.Message}
}
ch <- reply
delete(s.pending, command.Id)
}
return true, nil
}
package push
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync/atomic"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
checkinpb "github.com/portpowered/go-ring/third_party/go-push-receiver/pb/checkin"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
)
type DialContextFunc func(context.Context, string, string) (net.Conn, error)
type diagnosticTransport struct {
failed atomic.Value
next http.RoundTripper
}
// NewRegistrationTransport validates every outbound FCM HTTP route and applies
// the known registration compatibility transformations before forwarding it.
func NewRegistrationTransport(next http.RoundTripper) http.RoundTripper {
return newDiagnosticTransport(next)
}
func newDiagnosticTransport(next http.RoundTripper) *diagnosticTransport {
if next == nil {
next = http.DefaultTransport
}
return &diagnosticTransport{failed: atomic.Value{}, next: next}
}
type fcmHTTPRoute string
const (
routeCheckin fcmHTTPRoute = "checkin"
routeRegister fcmHTTPRoute = "register"
routeInstallations fcmHTTPRoute = "installations"
routeRegistrations fcmHTTPRoute = "registrations"
fcmFIDByteCount = 17
fcmWebPushP256DHByteCount = 65
fcmWebPushAuthSecretByteCount = 16
fcmRegistrationWebFieldCount = 3
)
func (t *diagnosticTransport) RoundTrip(request *http.Request) (*http.Response, error) {
route, err := matchFCMRoute(request)
if err != nil {
return nil, err
}
err = validateFCMHeaders(route, request)
if err != nil {
return nil, err
}
if route == routeRegistrations {
err = omitDefaultVAPID(request)
if err != nil {
return nil, err
}
}
body, err := readAndRestoreBody(request)
if err != nil {
return nil, err
}
err = validateFCMRequestBody(route, body)
if err != nil {
return nil, err
}
response, err := t.next.RoundTrip(request)
if response != nil && response.StatusCode >= http.StatusBadRequest {
t.failed.Store(request.URL.Host + request.URL.EscapedPath())
} else if err == nil {
t.failed.Store("")
}
if err != nil {
return response, ringerrors.NewNetworkError("FCM HTTP request failed", err)
}
return response, nil
}
func matchFCMRoute(request *http.Request) (fcmHTTPRoute, error) {
if request == nil || request.URL == nil ||
request.URL.Scheme != "https" || request.URL.User != nil ||
request.URL.RawQuery != "" || request.URL.ForceQuery || request.URL.Fragment != "" || request.URL.Opaque != "" ||
(request.Host != "" && request.Host != request.URL.Host) {
return "", ringerrors.NewNetworkError("unlisted FCM HTTP route", nil)
}
if request.Method != http.MethodPost {
return "", ringerrors.NewNetworkError("unlisted FCM HTTP route", nil)
}
switch {
case request.URL.Host == protocol.FCMCheckinHost && request.URL.EscapedPath() == protocol.FCMCheckinPath:
return routeCheckin, nil
case request.URL.Host == protocol.FCMRegisterHost && request.URL.EscapedPath() == protocol.FCMRegisterPath:
return routeRegister, nil
case request.URL.Host == protocol.FCMInstallationsHost && request.URL.EscapedPath() == protocol.FCMInstallationsPath:
return routeInstallations, nil
case request.URL.Host == protocol.FCMRegistrationsHost && request.URL.EscapedPath() == protocol.FCMRegistrationsPath:
return routeRegistrations, nil
default:
return "", ringerrors.NewNetworkError("unlisted FCM HTTP route", nil)
}
}
func validateFCMHeaders(route fcmHTTPRoute, request *http.Request) error {
var expected http.Header
switch route {
case routeCheckin:
expected = http.Header{protocol.FCMContentTypeHeader: {protocol.FCMContentTypeProtobuf}}
case routeRegister:
authorization := request.Header.Get("Authorization")
if !strings.HasPrefix(authorization, "AidLogin ") {
return invalidFCMHeaderError()
}
identifier, secret, ok := strings.Cut(strings.TrimPrefix(authorization, "AidLogin "), ":")
if !ok || !decimal(identifier) || !decimal(secret) {
return invalidFCMHeaderError()
}
expected = http.Header{
protocol.FCMContentTypeHeader: {protocol.FCMContentTypeForm},
"Authorization": {authorization},
"User-Agent": {""},
}
case routeInstallations:
expected = http.Header{
protocol.FCMContentTypeHeader: {protocol.FCMContentTypeJSON},
protocol.FCMAcceptHeader: {protocol.FCMContentTypeJSON},
protocol.FCMInstallationsAPIKeyHeader: {apiKey},
}
case routeRegistrations:
authorization := request.Header.Get(protocol.FCMInstallationsAuthHeader)
token := strings.TrimPrefix(authorization, protocol.FCMInstallationsAuthPrefix)
if strings.TrimSpace(token) == "" {
return invalidFCMHeaderError()
}
expected = http.Header{
protocol.FCMContentTypeHeader: {protocol.FCMContentTypeJSON},
protocol.FCMInstallationsAPIKeyHeader: {apiKey},
protocol.FCMInstallationsAuthHeader: {authorization},
}
default:
return ringerrors.NewNetworkError("unlisted FCM HTTP route", nil)
}
if !sameHeaderSet(expected, request.Header) {
return invalidFCMHeaderError()
}
return nil
}
func sameHeaderSet(expected, actual http.Header) bool {
if len(expected) != len(actual) {
return false
}
for name, values := range expected {
actualValues := actual.Values(name)
if len(values) != len(actualValues) {
return false
}
for index, value := range values {
if actualValues[index] != value {
return false
}
}
}
return true
}
func invalidFCMHeaderError() error {
return ringerrors.NewBadRequestError("FCM request headers do not match the pinned protocol", nil)
}
func decimal(value string) bool {
if value == "" {
return false
}
for _, digit := range value {
if digit < '0' || digit > '9' {
return false
}
}
return true
}
func readAndRestoreBody(request *http.Request) ([]byte, error) {
if request.Body == nil {
return nil, ringerrors.NewBadRequestError("FCM request body is required", nil)
}
body, err := io.ReadAll(request.Body)
if err != nil {
return nil, ringerrors.NewBadRequestError("read FCM request body", err)
}
request.Body = io.NopCloser(bytes.NewReader(body))
request.ContentLength = int64(len(body))
return body, nil
}
func validateFCMRequestBody(route fcmHTTPRoute, body []byte) error {
switch route {
case routeCheckin:
var request checkinpb.AndroidCheckinRequest
err := proto.Unmarshal(body, &request)
if err != nil {
return ringerrors.NewBadRequestError("decode FCM check-in request", err)
}
return validateCheckinRequest(&request)
case routeRegister:
return validateRegisterRequest(body)
case routeInstallations:
return validateInstallationsRequest(body)
case routeRegistrations:
return validateRegistrationsRequest(body)
default:
return ringerrors.NewNetworkError("unlisted FCM HTTP route", nil)
}
}
func validateCheckinRequest(request *checkinpb.AndroidCheckinRequest) error {
if request.GetVersion() != 3 || request.GetFragment() != 0 || request.GetUserSerialNumber() != 0 ||
request.GetCheckin() == nil || request.GetCheckin().GetUserNumber() != 0 ||
request.GetCheckin().GetType() != checkinpb.DeviceType_DEVICE_CHROME_BROWSER ||
request.GetCheckin().GetChromeBuild() == nil ||
request.GetCheckin().GetChromeBuild().GetPlatform() != checkinpb.ChromeBuildProto_PLATFORM_LINUX ||
request.GetCheckin().GetChromeBuild().GetChromeVersion() != "63.0.3234.0" ||
request.GetCheckin().GetChromeBuild().GetChannel() != checkinpb.ChromeBuildProto_CHANNEL_STABLE ||
!exactProtoFields(request, "id", "security_token", "checkin", "version", "fragment", "user_serial_number") ||
!exactProtoFields(request.GetCheckin(), "chrome_build", "type", "user_number") ||
!exactProtoFields(request.GetCheckin().GetChromeBuild(), "platform", "chrome_version", "channel") {
return ringerrors.NewBadRequestError("FCM check-in request violates its pinned protobuf contract", nil)
}
if len(request.ProtoReflect().GetUnknown()) > 0 || len(request.GetCheckin().ProtoReflect().GetUnknown()) > 0 ||
len(request.GetCheckin().GetChromeBuild().ProtoReflect().GetUnknown()) > 0 {
return ringerrors.NewBadRequestError("FCM check-in request contains unknown fields", nil)
}
return nil
}
func exactProtoFields(message proto.Message, names ...protoreflect.Name) bool {
wanted := make(map[protoreflect.Name]struct{}, len(names))
for _, name := range names {
wanted[name] = struct{}{}
}
valid := true
message.ProtoReflect().Range(func(field protoreflect.FieldDescriptor, _ protoreflect.Value) bool {
if _, ok := wanted[field.Name()]; !ok {
valid = false
return false
}
delete(wanted, field.Name())
return true
})
return valid && len(wanted) == 0
}
func validateRegisterRequest(body []byte) error {
values, err := url.ParseQuery(string(body))
if err != nil {
return ringerrors.NewBadRequestError("decode FCM legacy registration request", err)
}
if len(values) != 4 ||
!oneValueIs(values, protocol.FCMRegisterFormAppKey, protocol.FCMAndroidAppIdentifier) ||
!oneValueIs(values, protocol.FCMRegisterFormSubtypeKey, appID) ||
!oneValueIs(values, protocol.FCMRegisterFormDeviceKey, values.Get(protocol.FCMRegisterFormDeviceKey)) ||
!decimal(values.Get(protocol.FCMRegisterFormDeviceKey)) ||
!oneValueIs(values, protocol.FCMRegisterFormSenderKey, protocol.FCMDefaultVAPIDKey) {
return ringerrors.NewBadRequestError("FCM legacy registration request violates its pinned form contract", nil)
}
return nil
}
func validateInstallationsRequest(body []byte) error {
fields, err := jsonObject(body)
if err != nil {
return ringerrors.NewBadRequestError("decode Firebase installations request", err)
}
if !exactJSONKeys(fields, protocol.FCMInstallationsAppIDKey, protocol.FCMInstallationsAuthVersionKey,
protocol.FCMInstallationsFIDKey, protocol.FCMInstallationsSDKVersionKey) ||
!jsonStringIs(fields, protocol.FCMInstallationsAppIDKey, appID) ||
!jsonStringIs(fields, protocol.FCMInstallationsAuthVersionKey, protocol.FCMInstallationsAuthVersion) ||
!validFID(jsonString(fields, protocol.FCMInstallationsFIDKey)) ||
!jsonStringIs(fields, protocol.FCMInstallationsSDKVersionKey, protocol.FCMInstallationsSDKVersion) {
return ringerrors.NewBadRequestError("Firebase installations request violates its pinned JSON contract", nil)
}
return nil
}
func validateRegistrationsRequest(body []byte) error {
fields, err := jsonObject(body)
if err != nil {
return ringerrors.NewBadRequestError("decode FCM registrations request", err)
}
if !exactJSONKeys(fields, protocol.FCMRegistrationWebKey) {
return ringerrors.NewBadRequestError("FCM registrations request violates its pinned JSON contract", nil)
}
webFields, err := jsonObject(fields[protocol.FCMRegistrationWebKey])
if err != nil {
return ringerrors.NewBadRequestError("decode FCM registrations web request", err)
}
endpoint := jsonString(webFields, protocol.FCMRegistrationEndpointKey)
if !exactJSONKeys(webFields, protocol.FCMRegistrationEndpointKey, protocol.FCMRegistrationP256DHKey,
protocol.FCMRegistrationAuthKey) || !strings.HasPrefix(endpoint, protocol.FCMRegistrationEndpointPrefix) ||
strings.TrimPrefix(endpoint, protocol.FCMRegistrationEndpointPrefix) == "" ||
!validWebPushKey(jsonString(webFields, protocol.FCMRegistrationP256DHKey), fcmWebPushP256DHByteCount) ||
!validWebPushKey(jsonString(webFields, protocol.FCMRegistrationAuthKey), fcmWebPushAuthSecretByteCount) {
return ringerrors.NewBadRequestError("FCM registrations request violates its pinned JSON contract", nil)
}
return nil
}
func oneValueIs(values url.Values, name, want string) bool {
actual := values[name]
return len(actual) == 1 && actual[0] == want
}
func jsonObject(data []byte) (map[string]json.RawMessage, error) {
var fields map[string]json.RawMessage
err := json.Unmarshal(data, &fields)
if err != nil {
return nil, ringerrors.NewBadRequestError("decode FCM JSON request", err)
}
if fields == nil {
return nil, ringerrors.NewBadRequestError("FCM JSON request must be an object", nil)
}
return fields, nil
}
func exactJSONKeys(fields map[string]json.RawMessage, names ...string) bool {
if len(fields) != len(names) {
return false
}
for _, name := range names {
if _, ok := fields[name]; !ok {
return false
}
}
return true
}
func jsonString(fields map[string]json.RawMessage, name string) string {
var value string
if json.Unmarshal(fields[name], &value) != nil {
return ""
}
return value
}
func jsonStringIs(fields map[string]json.RawMessage, name, want string) bool {
return jsonString(fields, name) == want
}
func validFID(value string) bool {
decoded, err := base64.StdEncoding.Strict().DecodeString(value)
return err == nil && len(decoded) == fcmFIDByteCount && decoded[0]&0xf0 == 0x70
}
func validWebPushKey(value string, wantLength int) bool {
decoded, err := base64.URLEncoding.Strict().DecodeString(value)
return err == nil && len(decoded) == wantLength
}
// normalizeRegistrationCompatibility accepts the pinned receiver's legacy
// request shape and normalizes it to the generated external operation model.
// The generated caller already sends that normalized shape.
func omitDefaultVAPID(request *http.Request) error {
body, err := io.ReadAll(request.Body)
if err != nil {
return ringerrors.NewBadRequestError("read FCM registration request", err)
}
var fields map[string]json.RawMessage
err = json.Unmarshal(body, &fields)
if err != nil {
return ringerrors.NewBadRequestError("decode FCM registration request", err)
}
if !exactJSONKeys(fields, protocol.FCMRegistrationWebKey) {
return ringerrors.NewBadRequestError("FCM registration request violates its pinned JSON contract", nil)
}
var web map[string]json.RawMessage
err = json.Unmarshal(fields[protocol.FCMRegistrationWebKey], &web)
if err != nil {
return ringerrors.NewBadRequestError("decode FCM registration web request", err)
}
if !exactJSONKeys(web, protocol.FCMRegistrationEndpointKey,
protocol.FCMRegistrationP256DHKey, protocol.FCMRegistrationAuthKey) {
if !exactJSONKeys(web, protocol.FCMRegistrationVAPIDKey, protocol.FCMRegistrationEndpointKey,
protocol.FCMRegistrationP256DHKey, protocol.FCMRegistrationAuthKey) ||
!jsonStringIs(web, protocol.FCMRegistrationVAPIDKey, protocol.FCMDefaultVAPIDKey) {
return ringerrors.NewBadRequestError("FCM registration VAPID key does not match its pinned value", nil)
}
delete(web, protocol.FCMRegistrationVAPIDKey)
}
if len(web) != fcmRegistrationWebFieldCount {
return ringerrors.NewBadRequestError("FCM registration VAPID key does not match its pinned value", nil)
}
if _, legacyShape := web[protocol.FCMRegistrationVAPIDKey]; legacyShape {
return ringerrors.NewBadRequestError("FCM registration VAPID key was not removed", nil)
}
fields[protocol.FCMRegistrationWebKey], err = json.Marshal(web)
if err != nil {
return ringerrors.NewBadRequestError("encode FCM registration web request", err)
}
body, err = json.Marshal(fields)
if err != nil {
return ringerrors.NewBadRequestError("encode FCM registration request", err)
}
request.Body = io.NopCloser(bytes.NewReader(body))
request.ContentLength = int64(len(body))
authorization := request.Header.Get(protocol.FCMInstallationsAuthHeader)
token := strings.TrimPrefix(authorization, protocol.FCMInstallationsAuthPrefix)
if strings.TrimSpace(token) == "" {
return invalidFCMHeaderError()
}
request.Header.Set(protocol.FCMInstallationsAuthHeader, token)
return nil
}
// Package push adapts the long-lived FCM connection to Ring's account-scoped API.
package push
import (
"context"
"encoding/json"
"net/http"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
pushreceiver "github.com/portpowered/go-ring/third_party/go-push-receiver"
)
const (
eventBufferSize = 16
apiKey = protocol.FCMAPIKey // #nosec G101 -- public FCM client key.
projectID = protocol.FCMProjectID
appID = protocol.FCMApplicationID
)
type Kind string
const (
KindCredentials Kind = "credentials"
KindConnected Kind = "connected"
KindMessage Kind = "message"
KindRetry Kind = "retry"
KindClosed Kind = "closed"
)
type Event struct {
Kind Kind
Token string
Credentials json.RawMessage
Data json.RawMessage
Err error
}
// Start opens one FCM receiver. Credentials are caller-owned and can be
// persisted between runs; the receiver itself owns no Ring account state.
func Start(ctx context.Context, saved json.RawMessage) (<-chan Event, error) {
return StartWithHTTPTransport(ctx, saved, nil)
}
// StartWithHTTPTransport opens one FCM receiver using the supplied HTTP
// transport. A nil transport uses the standard HTTP transport.
func StartWithHTTPTransport(
ctx context.Context,
saved json.RawMessage,
transport http.RoundTripper,
) (<-chan Event, error) {
return StartWithTransports(ctx, saved, transport, nil)
}
// StartWithTransports opens the FCM receiver with injectable HTTP and MCS
// transports. The dial context function must return a connection ready for
// MCS frames; nil uses the receiver's TLS dialer.
func StartWithTransports(
ctx context.Context,
saved json.RawMessage,
transport http.RoundTripper,
dialContext DialContextFunc,
) (<-chan Event, error) {
options := make([]pushreceiver.ClientOption, 0, 2)
if len(saved) > 0 {
var credentials pushreceiver.FCMCredentials
err := json.Unmarshal(saved, &credentials)
if err != nil {
return nil, ringerrors.NewBadRequestError("decode saved FCM credentials", err)
}
if credentials.Token == "" {
return nil, ringerrors.NewBadRequestError("saved FCM credentials have no token", nil)
}
options = append(options, pushreceiver.WithCreds(&credentials))
}
diagnostics := newDiagnosticTransport(transport)
options = append(options, pushreceiver.WithHTTPClient(&http.Client{Transport: diagnostics}))
if dialContext != nil {
options = append(options, pushreceiver.WithMCSDialContext(pushreceiver.MCSDialContext(dialContext)))
}
client := pushreceiver.New(&pushreceiver.Config{
ApiKey: apiKey,
ProjectID: projectID,
AppID: appID,
VapidKey: protocol.FCMDefaultVAPIDKey,
}, options...)
out := make(chan Event, eventBufferSize)
go client.Subscribe(ctx)
go func() {
defer close(out)
if len(saved) > 0 {
var credentials pushreceiver.FCMCredentials
_ = json.Unmarshal(saved, &credentials)
if !send(ctx, out, Event{
Kind: KindCredentials,
Token: credentials.Token,
Credentials: saved,
Data: nil,
Err: nil,
}) {
return
}
}
for raw := range client.Events {
if ctx.Err() != nil {
return
}
event, ok := TranslateEvent(raw)
if event.Err != nil {
if stage, valid := diagnostics.failed.Load().(string); valid && stage != "" {
event.Err = ringerrors.NewConnectionError("FCM "+stage+" failed", event.Err)
}
}
if ok && !send(ctx, out, event) {
return
}
}
_ = send(ctx, out, Event{
Kind: KindClosed,
Token: "",
Credentials: nil,
Data: nil,
Err: nil,
})
}()
return out, nil
}
func send(ctx context.Context, out chan<- Event, event Event) bool {
select {
case out <- event:
return true
case <-ctx.Done():
return false
}
}
// TranslateEvent maps third-party receiver events into this package's event shape.
func TranslateEvent(raw pushreceiver.Event) (Event, bool) {
switch value := raw.(type) {
case *pushreceiver.UpdateCredentialsEvent:
data, err := json.Marshal(value.Credentials)
return Event{
Kind: KindCredentials,
Token: value.Credentials.Token,
Credentials: data,
Data: nil,
Err: err,
}, true
case *pushreceiver.ConnectedEvent:
return Event{
Kind: KindConnected,
Token: "",
Credentials: nil,
Data: nil,
Err: nil,
}, true
case *pushreceiver.MessageEvent:
return Event{
Kind: KindMessage,
Token: "",
Credentials: nil,
Data: value.Data,
Err: nil,
}, true
case *pushreceiver.RetryEvent:
return Event{
Kind: KindRetry,
Token: "",
Credentials: nil,
Data: nil,
Err: value.ErrorObj,
}, true
case *pushreceiver.UnauthorizedError:
return Event{
Kind: KindRetry,
Token: "",
Credentials: nil,
Data: nil,
Err: value.ErrorObj,
}, true
case *pushreceiver.DisconnectedEvent:
return Event{
Kind: KindClosed,
Token: "",
Credentials: nil,
Data: nil,
Err: nil,
}, true
default:
return Event{
Kind: "",
Token: "",
Credentials: nil,
Data: nil,
Err: nil,
}, false
}
}
package rest
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// TokenResponse is generated from the OAuthToken schema in api/openapi.yaml.
type TokenResponse = generatedhttp.OAuthToken
const (
csrfTokenKey = "csrftoken"
formURLEncodedContentType = "application/x-www-form-urlencoded"
)
type pkceState struct {
verifier string
state string
csrfToken string
redirectURI string
client *http.Client
}
var csrfPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)name=["']csrf-token["'][^>]*value=["']([^"']+)["']`),
regexp.MustCompile(`(?i)value=["']([^"']+)["'][^>]*name=["']csrf-token["']`),
regexp.MustCompile(`(?i)name=["'](?:csrfToken|csrf_token|_csrf)["'][^>]*value=["']([^"']+)["']`),
regexp.MustCompile(`(?i)name=["'](?:csrf-token|csrfToken)["'][^>]*content=["']([^"']+)["']`),
regexp.MustCompile(`(?i)["']csrf[-_]?[Tt]oken["']\s*[=:]\s*["']([^"']+)["']`),
}
// Authenticate performs Ring's OAuth 2.0 authorization-code flow with PKCE.
// The pending browser session is retained between the initial 2FA request and
// the subsequent call containing the verification code.
func (c *Client) Authenticate(
ctx context.Context,
username, password, hardwareID, otpCode string,
) (*TokenResponse, error) {
c.authMu.Lock()
defer c.authMu.Unlock()
fallback, err := c.prepareAuthentication(ctx, username, password, hardwareID, otpCode)
if err != nil {
return nil, err
}
if fallback {
return c.authenticateLegacy(ctx, username, password, hardwareID, otpCode)
}
code, err := c.authorizationCode(ctx)
if err != nil {
return nil, err
}
response, err := c.exchangeAuthorizationCode(ctx, code, hardwareID)
if err != nil {
return nil, err
}
c.pendingPKCE = nil
// Ring's session APIs may reject the access token returned directly by the
// authorization-code exchange. Rotating it once produces the normal API
// access token and also ensures callers persist the current refresh token.
if response.RefreshToken != nil && *response.RefreshToken != "" {
return c.refreshAccessToken(ctx, *response.RefreshToken, hardwareID)
}
return response, nil
}
func (c *Client) prepareAuthentication(
ctx context.Context, username, password, hardwareID, otpCode string,
) (bool, error) {
if c.pendingPKCE != nil {
if otpCode == "" {
return false, ringerrors.NewRequires2FAError("2FA code required")
}
return false, c.verify2FA(ctx, otpCode)
}
err := c.startPKCE(ctx, username, password, hardwareID, otpCode)
if err == nil {
return false, nil
}
var authErr *ringerrors.AuthenticationError
if errors.As(err, &authErr) && authErr.Status == http.StatusNotFound {
return true, nil
}
return false, err
}
func (c *Client) startPKCE(ctx context.Context, username, password, hardwareID, otpCode string) error {
err := c.initiatePKCE(ctx, hardwareID)
if err != nil {
return err
}
requires2FA, err := c.submitCredentials(ctx, username, password)
if err != nil {
return err
}
if requires2FA && otpCode == "" {
return ringerrors.NewRequires2FAError("2FA code required")
}
if requires2FA {
return c.verify2FA(ctx, otpCode)
}
return nil
}
// authenticateLegacy preserves compatibility with older Ring deployments and
// with callers that provide an HTTP test double for the historical endpoint.
func (c *Client) authenticateLegacy(
ctx context.Context,
username, password, hardwareID, otpCode string,
) (*TokenResponse, error) {
data := url.Values{
"grant_type": {"password"}, "username": {username}, "password": {password},
"client_id": {protocol.RingClientID}, "scope": {protocol.RingScope},
}
req, err := generatedhttp.NewExchangeOrRefreshOAuthTokenRequestWithBody(
generatedServerBase(c.oauthBaseURI), formURLEncodedContentType, strings.NewReader(data.Encode()),
)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to create legacy auth request", err)
}
req = req.WithContext(ctx)
req.Header.Set(string(generatedhttp.ContentType), formURLEncodedContentType)
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID != "" {
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
if otpCode != "" {
req.Header.Set(string(generatedhttp.N2faSupport), "true")
req.Header.Set(string(generatedhttp.N2faCode), otpCode)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, ringerrors.NewNetworkError("legacy auth request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return decodeTokenResponse(resp)
}
// Request2FACode starts the PKCE login flow and causes Ring to deliver a code.
func (c *Client) Request2FACode(ctx context.Context, username, password, hardwareID string) error {
_, err := c.Authenticate(ctx, username, password, hardwareID, "")
if ringerrors.IsRequires2FAError(err) {
return nil
}
return err
}
func (c *Client) initiatePKCE(ctx context.Context, hardwareID string) error {
const (
verifierBytesCount = 32
stateBytesCount = 16
)
verifierBytes := make([]byte, verifierBytesCount)
stateBytes := make([]byte, stateBytesCount)
{
_, err := rand.Read(verifierBytes)
if err != nil {
return ringerrors.NewInternalServerError("failed to generate PKCE verifier", err)
}
}
{
_, err := rand.Read(stateBytes)
if err != nil {
return ringerrors.NewInternalServerError("failed to generate OAuth state", err)
}
}
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
challengeBytes := sha256.Sum256([]byte(verifier))
state := hex.EncodeToString(stateBytes)
redirectURI := protocol.OAuthCallbackURL
jar, err := cookiejar.New(nil)
if err != nil {
return ringerrors.NewInternalServerError("failed to create OAuth cookie jar", err)
}
authClientCopy := *c.httpClient
authClientCopy.Jar = jar
authClientCopy.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
authClient := &authClientCopy
params := oauthAuthorizationValues(redirectURI, state, hardwareID, challengeBytes)
currentURL := strings.TrimSuffix(c.oauthBaseURI, "/") + protocol.OAuthAuthorizePath + "?" + params.Encode()
var html string
for range 5 {
resp, err := c.sendOAuthAuthorize(ctx, authClient, currentURL)
if err != nil {
return err
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
location := resp.Header.Get("Location")
_ = resp.Body.Close()
if location == "" {
return ringerrors.NewAuthenticationError("OAuth redirect missing location", resp.StatusCode)
}
next, err := resolveOAuthURL(currentURL, location)
if err != nil {
return ringerrors.NewAuthenticationError("invalid OAuth redirect", resp.StatusCode)
}
currentURL = next
continue
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return ringerrors.NewNetworkError("failed to read OAuth sign-in page", readErr)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ringerrors.NewAuthenticationError("failed to load OAuth sign-in page", resp.StatusCode)
}
html = string(body)
break
}
csrfToken := extractCSRF(html, jar, c.oauthBaseURI)
if csrfToken == "" {
return ringerrors.NewAuthenticationError(
"unable to extract CSRF token from Ring OAuth page",
http.StatusUnauthorized,
)
}
c.pendingPKCE = &pkceState{
verifier: verifier,
state: state,
csrfToken: csrfToken,
redirectURI: redirectURI,
client: authClient,
}
return nil
}
func oauthAuthorizationValues(redirectURI, state, hardwareID string, challengeBytes [32]byte) url.Values {
return url.Values{
"redirect_uri": {redirectURI},
"client_id": {protocol.RingClientID},
"response_type": {"code"},
"prompt": {"login"},
"state": {state},
"scope": {protocol.RingScope},
"code_challenge": {base64.RawURLEncoding.EncodeToString(challengeBytes[:])},
"code_challenge_method": {"S256"},
"device_model": {string(deviceModel)},
"app_version": {"3.102.0"},
"dark_mode": {"false"},
"device_brand": {"golang"},
"device_os_version": {"go"},
"app_brand": {"ring"},
"hardware_id": {hardwareID},
}
}
func (c *Client) submitCredentials(ctx context.Context, username, password string) (bool, error) {
pending := c.pendingPKCE
form := url.Values{"username": {username}, "password": {password}, "csrf-token": {pending.csrfToken}}
resp, body, err := c.authFormRequest(ctx, pending.client, "/oauth/v2/signin", form)
if err != nil {
return false, err
}
var payload generatedhttp.SignInState
_ = json.Unmarshal(body, &payload)
location := resp.Header.Get("Location")
requires2FA := resp.StatusCode == http.StatusPreconditionFailed ||
(payload.TsvState != nil && *payload.TsvState != "") ||
payload.NextTimeInSecs != nil ||
strings.Contains(location, "/2fa")
if requires2FA {
return true, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false, ringerrors.NewAuthenticationError("Ring sign-in rejected credentials", resp.StatusCode)
}
return false, nil
}
func (c *Client) verify2FA(ctx context.Context, code string) error {
pending := c.pendingPKCE
form := url.Values{"2fa_code": {code}, "csrf-token": {pending.csrfToken}, "remember_me": {"false"}}
resp, _, err := c.authFormRequest(ctx, pending.client, "/oauth/v2/2fa/verify", form)
if err != nil {
return err
}
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnauthorized {
return ringerrors.NewAuthenticationError("verification code is invalid or expired", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return ringerrors.NewAuthenticationError("2FA verification failed", resp.StatusCode)
}
return nil
}
type authFormResponse struct {
StatusCode int
Header http.Header
}
func (c *Client) authFormRequest(
ctx context.Context,
client *http.Client,
path string,
form url.Values,
) (*authFormResponse, []byte, error) {
var (
req *http.Request
err error
)
switch path {
case "/oauth/v2/signin":
req, err = generatedhttp.NewSubmitOAuthCredentialsRequestWithBody(
generatedServerBase(c.oauthBaseURI), formURLEncodedContentType, strings.NewReader(form.Encode()),
)
case "/oauth/v2/2fa/verify":
req, err = generatedhttp.NewVerifyOAuthTwoFactorCodeRequestWithBody(
generatedServerBase(c.oauthBaseURI), formURLEncodedContentType, strings.NewReader(form.Encode()),
)
default:
return nil, nil, ringerrors.NewBadRequestError("unsupported OAuth form route", nil)
}
if err != nil {
return nil, nil, ringerrors.NewNetworkError("failed to create OAuth request", err)
}
req = req.WithContext(ctx)
req.Header.Set(string(generatedhttp.ContentType), formURLEncodedContentType)
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, nil, ringerrors.NewNetworkError("OAuth request failed", err)
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, nil, ringerrors.NewNetworkError("failed to read OAuth response", readErr)
}
return &authFormResponse{StatusCode: resp.StatusCode, Header: resp.Header.Clone()}, body, nil
}
func (c *Client) authorizationCode(ctx context.Context) (string, error) {
pending := c.pendingPKCE
currentURL := strings.TrimSuffix(c.oauthBaseURI, "/") + protocol.OAuthAuthorizePath
for range 5 {
resp, err := c.sendOAuthAuthorize(ctx, pending.client, currentURL)
if err != nil {
return "", err
}
location := resp.Header.Get("Location")
_ = resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode >= 400 || location == "" {
return "", ringerrors.NewAuthenticationError("OAuth authorize endpoint did not redirect", resp.StatusCode)
}
next, err := resolveOAuthURL(currentURL, location)
if err != nil {
return "", ringerrors.NewAuthenticationError("invalid OAuth authorization redirect", resp.StatusCode)
}
redirect, err := url.Parse(next)
if err == nil && redirect.Query().Get("code") != "" {
if redirect.Query().Get("state") != pending.state {
return "", ringerrors.NewAuthenticationError("OAuth state mismatch", http.StatusUnauthorized)
}
return redirect.Query().Get("code"), nil
}
currentURL = next
}
return "", ringerrors.NewAuthenticationError(
"OAuth authorization code redirect limit exceeded",
http.StatusUnauthorized,
)
}
func (c *Client) exchangeAuthorizationCode(ctx context.Context, code, hardwareID string) (*TokenResponse, error) {
pending := c.pendingPKCE
form := url.Values{
"code": {code}, "grant_type": {"authorization_code"}, "redirect_uri": {pending.redirectURI},
"code_verifier": {pending.verifier}, "client_id": {protocol.RingClientID},
}
req, err := generatedhttp.NewExchangeOrRefreshOAuthTokenRequestWithBody(
generatedServerBase(c.oauthBaseURI), formURLEncodedContentType, strings.NewReader(form.Encode()),
)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to create OAuth token request", err)
}
req = req.WithContext(ctx)
req.Header.Set(string(generatedhttp.ContentType), formURLEncodedContentType)
req.Header.Set(string(generatedhttp.Accept), "application/json")
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
resp, err := pending.client.Do(req)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to exchange OAuth authorization code", err)
}
defer func() { _ = resp.Body.Close() }()
return decodeTokenResponse(resp)
}
// RefreshAccessToken refreshes an access token using a refresh token.
func (c *Client) RefreshAccessToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
return c.refreshAccessToken(ctx, refreshToken, c.hardwareID)
}
// RefreshAccessTokenFor uses a caller-supplied device identity without
// changing authorization state on a shared REST client.
func (c *Client) RefreshAccessTokenFor(ctx context.Context, refreshToken, hardwareID string) (*TokenResponse, error) {
return c.refreshAccessToken(ctx, refreshToken, hardwareID)
}
func (c *Client) refreshAccessToken(ctx context.Context, refreshToken, hardwareID string) (*TokenResponse, error) {
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {protocol.RingClientID},
"scope": {protocol.RingScope},
}
req, err := generatedhttp.NewExchangeOrRefreshOAuthTokenRequestWithBody(
generatedServerBase(c.oauthBaseURI), formURLEncodedContentType, strings.NewReader(data.Encode()),
)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to create refresh request", err)
}
req = req.WithContext(ctx)
req.Header.Set(string(generatedhttp.ContentType), formURLEncodedContentType)
req.Header.Set(string(generatedhttp.Accept), "application/json")
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID != "" {
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to refresh access token", err)
}
defer func() { _ = resp.Body.Close() }()
return decodeTokenResponse(resp)
}
func decodeTokenResponse(resp *http.Response) (*TokenResponse, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to read token response", err)
}
if resp.StatusCode == http.StatusPreconditionFailed {
return nil, ringerrors.NewRequires2FAError("2FA code required")
}
if resp.StatusCode == http.StatusTooManyRequests {
return nil, ringerrors.NewRateLimitError("rate limit exceeded")
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, ringerrors.NewAuthenticationError("token request rejected", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, ringerrors.ClassifyHTTPError(resp, string(body))
}
var tokenResponse TokenResponse
{
err := json.Unmarshal(body, &tokenResponse)
if err != nil {
return nil, ringerrors.NewInternalServerError("failed to parse token response", err)
}
}
if tokenResponse.AccessToken == "" {
return nil, ringerrors.NewInternalServerError("token response missing access token", nil)
}
return &tokenResponse, nil
}
func resolveOAuthURL(base, location string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", ringerrors.NewInternalServerError("invalid OAuth base URL", err)
}
next, err := url.Parse(location)
if err != nil {
return "", ringerrors.NewBadRequestError("invalid OAuth redirect URL", err)
}
return baseURL.ResolveReference(next).String(), nil
}
func extractCSRF(html string, jar http.CookieJar, oauthBase string) string {
for _, rawURL := range []string{oauthBase, oauthBase + protocol.OAuthSigninPath} {
parsed, _ := url.Parse(rawURL)
for _, cookie := range jar.Cookies(parsed) {
switch strings.ToLower(cookie.Name) {
case "csrf-token", csrfTokenKey, "csrf_token", "_csrf", "xsrf-token":
if cookie.Value != "" {
return cookie.Value
}
}
}
}
for _, pattern := range csrfPatterns {
if match := pattern.FindStringSubmatch(html); len(match) == 2 {
return match[1]
}
}
for _, scriptID := range []string{"oauth-args", "__NEXT_DATA__"} {
pattern := regexp.MustCompile(
`(?s)<script[^>]*id=["']` + regexp.QuoteMeta(scriptID) + `["'][^>]*>(.*?)</script>`,
)
if match := pattern.FindStringSubmatch(html); len(match) == 2 {
var value any
if json.Unmarshal([]byte(match[1]), &value) == nil {
if token := findCSRF(value, 0); token != "" {
return token
}
}
}
}
return ""
}
func findCSRF(value any, depth int) string {
if depth > maxCSRFSearchDepth {
return ""
}
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.NewReplacer("-", "", "_", "").Replace(strings.ToLower(key))
if normalized == csrfTokenKey || normalized == "csrf" {
if token, ok := child.(string); ok {
return token
}
}
if token := findCSRF(child, depth+1); token != "" {
return token
}
}
case []any:
for _, child := range typed {
if token := findCSRF(child, depth+1); token != "" {
return token
}
}
}
return ""
}
package rest
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// sendOAuthAuthorize follows only the schema-defined authorization route on
// the configured OAuth origin. Redirects to other paths are never fetched.
func (c *Client) sendOAuthAuthorize(
ctx context.Context, authClient *http.Client, currentURL string,
) (*http.Response, error) {
candidate, err := url.Parse(currentURL)
if err != nil {
return nil, ringerrors.NewBadRequestError("invalid OAuth authorization URL", err)
}
base, err := url.Parse(c.oauthBaseURI)
if err != nil {
return nil, ringerrors.NewBadRequestError("invalid OAuth origin", err)
}
expectedPath := strings.TrimSuffix(base.EscapedPath(), "/") + protocol.OAuthAuthorizePath
if candidate.Scheme != base.Scheme || !strings.EqualFold(candidate.Host, base.Host) ||
candidate.EscapedPath() != expectedPath || candidate.Fragment != "" {
return nil, ringerrors.NewBadRequestError("OAuth redirect is outside the authorization operation", nil)
}
query := candidate.Query()
for key, values := range query {
if len(values) != 1 || !oauthAuthorizeParameter(key) {
return nil, ringerrors.NewBadRequestError("OAuth redirect has an unsupported query parameter", nil)
}
}
value := func(name string) *string {
values := query[name]
if len(values) == 0 {
return nil
}
return &values[0]
}
var responseType *generatedhttp.BeginOrContinueOAuthAuthorizationParamsResponseType
if raw := value("response_type"); raw != nil {
if *raw != "code" {
return nil, ringerrors.NewBadRequestError("unsupported OAuth response type", nil)
}
typed := generatedhttp.BeginOrContinueOAuthAuthorizationParamsResponseType(*raw)
responseType = &typed
}
var challengeMethod *generatedhttp.BeginOrContinueOAuthAuthorizationParamsCodeChallengeMethod
if raw := value("code_challenge_method"); raw != nil {
if *raw != "S256" {
return nil, ringerrors.NewBadRequestError("unsupported OAuth challenge method", nil)
}
typed := generatedhttp.BeginOrContinueOAuthAuthorizationParamsCodeChallengeMethod(*raw)
challengeMethod = &typed
}
var darkMode *bool
if raw := value("dark_mode"); raw != nil {
parsed, parseErr := strconv.ParseBool(*raw)
if parseErr != nil {
return nil, ringerrors.NewBadRequestError("invalid OAuth dark mode", parseErr)
}
darkMode = &parsed
}
var model *generatedhttp.ClientDeviceModel
if raw := value("device_model"); raw != nil {
model, err = parseClientDeviceModel(*raw)
if err != nil {
return nil, err
}
}
params := &generatedhttp.BeginOrContinueOAuthAuthorizationParams{
RedirectUri: value("redirect_uri"),
ClientId: value("client_id"),
ResponseType: responseType,
Prompt: value("prompt"),
State: value("state"),
Scope: value("scope"),
CodeChallenge: value("code_challenge"),
CodeChallengeMethod: challengeMethod,
DeviceModel: model,
AppVersion: value("app_version"),
DarkMode: darkMode,
DeviceBrand: value("device_brand"),
DeviceOsVersion: value("device_os_version"),
AppBrand: value("app_brand"),
HardwareId: value("hardware_id"),
}
req, err := generatedhttp.NewBeginOrContinueOAuthAuthorizationRequest(generatedServerBase(c.oauthBaseURI), params)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build OAuth authorization request", err)
}
req = req.WithContext(ctx)
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
resp, err := authClient.Do(req)
if err != nil {
return nil, ringerrors.NewNetworkError("OAuth authorization request failed", err)
}
return resp, nil
}
func parseClientDeviceModel(value string) (*generatedhttp.ClientDeviceModel, error) {
if value != string(deviceModel) {
return nil, ringerrors.NewBadRequestError("unsupported OAuth device model", nil)
}
model := deviceModel
return &model, nil
}
func oauthAuthorizeParameter(name string) bool {
switch name {
case "redirect_uri", "client_id", "response_type", "prompt", "state", "scope", "code_challenge",
"code_challenge_method", "device_model", "app_version", "dark_mode", "device_brand",
"device_os_version", "app_brand", "hardware_id":
return true
default:
return false
}
}
package rest
import (
"context"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// GetDeviceDetail returns the captured v3 detail envelope.
func (c *Client) GetDeviceDetail(ctx context.Context, id int64) (*generatedhttp.DeviceDetail, error) {
var result generatedhttp.DeviceDetail
req, err := generatedhttp.NewGetDeviceRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build device detail request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) ListLocations(ctx context.Context) (*generatedhttp.LocationList, error) {
var result generatedhttp.LocationList
req, err := generatedhttp.NewListLocationsRequest(generatedServerBase(c.baseURI))
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build locations request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) GetLocation(
ctx context.Context,
id string,
params generatedhttp.GetLocationParams,
) (*generatedhttp.LocationDetail, error) {
var result generatedhttp.LocationDetail
req, err := generatedhttp.NewGetLocationRequest(generatedServerBase(c.baseURI), id, ¶ms)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build location request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) ListLocationGroups(ctx context.Context, id string) (*generatedhttp.LocationGroups, error) {
var result generatedhttp.LocationGroups
req, err := generatedhttp.NewListLocationGroupsRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build location groups request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) ListLocationDevices(ctx context.Context, id string) (*generatedhttp.LocationGroupDevices, error) {
var result generatedhttp.LocationGroupDevices
req, err := generatedhttp.NewListLocationDevicesRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build location devices request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) GetDeviceTimeline(
ctx context.Context,
id int64,
params generatedhttp.GetDeviceTimelineParams,
) (*generatedhttp.DeviceTimeline, error) {
var result generatedhttp.DeviceTimeline
req, err := generatedhttp.NewGetDeviceTimelineRequest(generatedServerBase(c.baseURI), id, ¶ms)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build device timeline request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) GetHistoryDevices(
ctx context.Context,
params generatedhttp.GetHistoryDevicesParams,
) (*generatedhttp.HistoryDevices, error) {
var result generatedhttp.HistoryDevices
req, err := generatedhttp.NewGetHistoryDevicesRequest(generatedServerBase(c.baseURI), ¶ms)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build history devices request", err)
}
err = c.doGeneratedJSON(ctx, req, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) RebootDevice(ctx context.Context, id int64) error {
command := generatedhttp.DeviceCommand{CommandName: generatedhttp.Reboot}
req, err := generatedhttp.NewSendDeviceCommandRequest(generatedServerBase(c.baseURI), id, command)
if err != nil {
return ringerrors.NewNetworkError("failed to build reboot request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
// UnlockIntercom sends the documented device RPC to an intercom.
func (c *Client) UnlockIntercom(ctx context.Context, id int64) error {
command := generatedhttp.IntercomUnlockCommand{
CommandName: generatedhttp.DeviceRpc,
Request: generatedhttp.IntercomUnlockRPC{
Jsonrpc: generatedhttp.N20,
Method: generatedhttp.UnlockDoor,
Params: generatedhttp.IntercomUnlockParams{
DoorId: generatedhttp.IntercomUnlockParamsDoorIdN0,
UserId: generatedhttp.IntercomUnlockParamsUserIdN0,
},
},
}
req, err := generatedhttp.NewUnlockIntercomRequest(generatedServerBase(c.baseURI), id, command)
if err != nil {
return ringerrors.NewNetworkError("failed to build intercom unlock request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
func (c *Client) SetPersistentLiveViewEnabled(ctx context.Context, id int64, enabled bool) error {
body := generatedhttp.LiveViewSettingRequest{Entity: generatedhttp.LiveViewSetting{LiveViewEnabled: enabled}}
req, err := generatedhttp.NewSetLiveViewEnabledRequest(generatedServerBase(c.baseURI), id, body)
if err != nil {
return ringerrors.NewNetworkError("failed to build live view settings request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
func (c *Client) FavoriteRecording(ctx context.Context, id int64) error {
req, err := generatedhttp.NewFavoriteRecordingRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return ringerrors.NewNetworkError("failed to build favorite recording request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
func (c *Client) DeleteRecording(ctx context.Context, id int64, confirmFavorite *bool) error {
params := &generatedhttp.DeleteRecordingParams{ConfirmDeleteFavorite: confirmFavorite}
req, err := generatedhttp.NewDeleteRecordingRequest(generatedServerBase(c.baseURI), id, params)
if err != nil {
return ringerrors.NewNetworkError("failed to build delete recording request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
// Package rest provides a REST API client for interacting with Ring services.
// It supports authentication, device enumeration, control, and state management via HTTP/RESTful APIs.
package rest
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/requestauth"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// Client is a REST API client for Ring services.
type Client struct {
httpClient *http.Client
configurationErr error
baseURI string
oauthBaseURI string
userAgent string
hardwareID string
authMu sync.Mutex
pendingPKCE *pkceState
}
// HTTPClientConfigurationError reports an unsafe or unusable HTTP client
// supplied to a reusable REST client.
type HTTPClientConfigurationError struct {
Reason string
}
func (e *HTTPClientConfigurationError) Error() string {
if e == nil || e.Reason == "" {
return "HTTP client configuration error"
}
return "HTTP client configuration error: " + e.Reason
}
type configurationErrorTransport struct {
err error
}
func (t configurationErrorTransport) RoundTrip(_ *http.Request) (*http.Response, error) {
return nil, t.err
}
// ClientOption is a function that configures a Client.
type ClientOption func(*Client)
// WithHTTPClient sets a custom HTTP client snapshot. A configured cookie jar
// is reported by Client.ConfigurationError and blocks requests.
func WithHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) {
if httpClient == nil {
configurationErr := &HTTPClientConfigurationError{Reason: "HTTP client must not be nil"}
client.httpClient = &http.Client{Transport: configurationErrorTransport{err: configurationErr}}
client.configurationErr = configurationErr
return
}
httpClientSnapshot := *httpClient
if httpClientSnapshot.Jar != nil {
configurationErr := &HTTPClientConfigurationError{
Reason: "a reusable REST client cannot share an HTTP cookie jar across account requests",
}
httpClientSnapshot.Transport = configurationErrorTransport{err: configurationErr}
client.httpClient = &httpClientSnapshot
client.configurationErr = configurationErr
return
}
client.httpClient = &httpClientSnapshot
client.configurationErr = nil
}
}
// WithBaseURI sets the base URL for the Ring API.
func WithBaseURI(baseURL string) ClientOption {
return func(c *Client) {
c.baseURI = baseURL
}
}
// WithEndpointBases configures per-client Ring service origins.
func WithEndpointBases(apiBase, oauthBase string) ClientOption {
return func(c *Client) { c.baseURI, c.oauthBaseURI = apiBase, oauthBase }
}
// WithUserAgent sets a custom user agent.
func WithUserAgent(userAgent string) ClientOption {
return func(c *Client) {
c.userAgent = userAgent
}
}
// WithHardwareID sets the hardware ID for authentication.
func WithHardwareID(hardwareID string) ClientOption {
return func(c *Client) {
c.hardwareID = hardwareID
}
}
// NewClient creates a new REST API client.
func NewClient(opts ...ClientOption) *Client {
client := &Client{
baseURI: protocol.APIBaseURL,
oauthBaseURI: protocol.OAuthBaseURL,
userAgent: protocol.DefaultUserAgent,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
for _, opt := range opts {
opt(client)
}
return client
}
// Apply applies the options to the client.
func (c *Client) Apply(opts ...ClientOption) {
for _, opt := range opts {
opt(c)
}
}
// ConfigurationError returns an inspectable configuration error, if an
// option supplied an HTTP client that cannot be used safely.
func (c *Client) ConfigurationError() error {
return c.configurationErr
}
// HTTPClient returns a shallow copy of the configured HTTP client. Its
// Transport remains shared so connection pooling and custom transports work.
func (c *Client) HTTPClient() *http.Client {
httpClient := *c.httpClient
return &httpClient
}
// generatedServerBase preserves a caller-supplied base path when generated
// operation paths are resolved relative to that base URL.
func generatedServerBase(base string) string {
parsed, err := url.Parse(base)
if err != nil {
return base
}
if parsed.Path != "" && parsed.Path != "/" && parsed.Path[len(parsed.Path)-1] != '/' {
parsed.Path += "/"
parsed.RawPath = ""
}
return parsed.String()
}
// getToken retrieves credentials scoped to the current operation.
func (c *Client) getToken(ctx context.Context) (string, error) {
if auth, ok := requestauth.FromContext(ctx); ok {
if auth.AccessToken == "" {
return "", ringerrors.NewTokenError("no token in request", nil)
}
return auth.AccessToken, nil
}
return "", ringerrors.NewTokenError("no token in request", nil)
}
func (c *Client) hardwareIDFor(ctx context.Context) string {
if auth, ok := requestauth.FromContext(ctx); ok {
return auth.HardwareID
}
return c.hardwareID
}
func (c *Client) sendAuthorizedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
err := ctx.Err()
if err != nil {
return nil, ringerrors.NewNetworkError("request canceled", err)
}
req = req.WithContext(ctx)
// Get token and set authorization header
token, err := c.getToken(ctx)
if err != nil {
return nil, ringerrors.NewTokenError("failed to retrieve access token", err)
}
req.Header.Set(string(generatedhttp.Authorization), "Bearer "+token)
if req.Header.Get("Content-Type") == "" {
req.Header.Set(string(generatedhttp.ContentType), "application/json")
}
if req.Header.Get("Accept") == "" {
req.Header.Set(string(generatedhttp.Accept), "application/json")
}
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
return c.sendWithRetry(ctx, req)
}
func (c *Client) sendWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
// Retry logic
maxRetries := 1
if req.Method == http.MethodGet || req.Method == http.MethodHead {
maxRetries = 3
}
var (
resp *http.Response
err error
)
for retryIndex := range maxRetries {
attemptReq := req
if retryIndex > 0 {
attemptReq = req.Clone(ctx)
if req.GetBody != nil {
attemptReq.Body, err = req.GetBody()
if err != nil {
return nil, ringerrors.NewNetworkError("failed to recreate request body", err)
}
}
}
resp, err = c.httpClient.Do(attemptReq)
if err == nil && resp.StatusCode < 500 {
break
}
if retryIndex < maxRetries-1 {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
// Exponential backoff
backoff := time.Duration(retryIndex+1) * time.Second
select {
case <-ctx.Done():
return nil, ringerrors.NewNetworkError("request canceled during retry", ctx.Err())
case <-time.After(backoff):
}
}
}
if err != nil {
return nil, ringerrors.NewNetworkError("request failed after retries", err)
}
return resp, nil
}
// doGeneratedJSON sends a request built by a generated OpenAPI operation.
func (c *Client) doGeneratedJSON(ctx context.Context, req *http.Request, result interface{}) error {
resp, err := c.sendAuthorizedRequest(ctx, req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return decodeJSONResponse(resp, result)
}
func decodeJSONResponse(resp *http.Response, result interface{}) error {
// Read the response body before decoding so the transport can be reused.
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return ringerrors.NewNetworkError("failed to read response body", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ringerrors.ClassifyHTTPError(resp, string(bodyBytes))
}
if result != nil {
err := json.Unmarshal(bodyBytes, result)
if err != nil {
return ringerrors.NewInternalServerError("failed to decode response", err)
}
}
return nil
}
package rest
import (
"context"
"encoding/json"
"net/http"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// GetMotionDetectionEnabled reads the one settings field supported by the current
// typed public settings API. Other response fields are intentionally ignored.
func (c *Client) GetMotionDetectionEnabled(ctx context.Context, deviceID int64) (*bool, error) {
var response generatedhttp.DeviceSettings
req, err := generatedhttp.NewGetDeviceSettingsRequest(generatedServerBase(c.baseURI), deviceID)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build device settings request", err)
}
err = c.doGeneratedJSON(ctx, req, &response)
if err != nil {
return nil, err
}
if response.MotionSettings == nil {
// A missing setting is a valid unknown state for this optional endpoint.
//nolint:nilnil // nil, nil represents the API's missing motion state.
return nil, nil
}
return response.MotionSettings.MotionDetectionEnabled, nil
}
// PatchMotionDetectionEnabled changes only the captured motion detection field.
func (c *Client) PatchMotionDetectionEnabled(ctx context.Context, deviceID int64, enabled bool) error {
body := generatedhttp.DeviceSettingsPatch{
GeneralSettings: nil,
MotionSettings: &generatedhttp.MotionSettingsPatch{
AdditionalProperties: nil,
MotionDetectionEnabled: &enabled,
},
VideoSettings: nil,
VolumeSettings: nil,
AdditionalProperties: nil,
}
var response json.RawMessage
req, err := generatedhttp.NewPatchDeviceSettingsRequest(generatedServerBase(c.baseURI), deviceID, body)
if err != nil {
return ringerrors.NewNetworkError("failed to build device settings update request", err)
}
return c.doGeneratedJSON(ctx, req, &response)
}
// SetSiren toggles the captured legacy doorbot siren route. Duration is not
// configurable: the capture establishes the server's response duration only.
func (c *Client) SetSiren(ctx context.Context, deviceID int64, enabled bool) error {
var (
req *http.Request
err error
)
if enabled {
req, err = generatedhttp.NewTurnSirenOnRequest(generatedServerBase(c.baseURI), deviceID)
} else {
req, err = generatedhttp.NewTurnSirenOffRequest(generatedServerBase(c.baseURI), deviceID)
}
if err != nil {
return ringerrors.NewNetworkError("failed to build siren request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
// SetChimeVolume uses the Python legacy chime update profile.
func (c *Client) SetChimeVolume(ctx context.Context, deviceID int64, description string, volume int) error {
params := &generatedhttp.SetChimeVolumeParams{
ChimeDescription: description,
ChimeSettingsVolume: volume,
}
request, err := generatedhttp.NewSetChimeVolumeRequest(generatedServerBase(c.baseURI), deviceID, params)
if err != nil {
return ringerrors.NewNetworkError("failed to build chime volume request", err)
}
return c.doGeneratedJSON(ctx, request, nil)
}
// SetDoorbellVolume uses the Python legacy doorbot update profile.
func (c *Client) SetDoorbellVolume(ctx context.Context, deviceID int64, description string, volume int) error {
return c.updateLegacyDoorbotControls(ctx, deviceID, &generatedhttp.UpdateLegacyDoorbotControlsParams{
DoorbotDescription: description,
DoorbotSettingsDoorbellVolume: &volume,
DoorbotSettingsChimeSettingsType: nil,
DoorbotSettingsChimeSettingsEnable: nil,
DoorbotSettingsChimeSettingsDuration: nil,
})
}
// SetLights uses the captured on route and the corresponding legacy off route.
func (c *Client) SetLights(ctx context.Context, deviceID int64, enabled bool) error {
var (
req *http.Request
err error
)
if enabled {
req, err = generatedhttp.NewTurnFloodlightOnRequest(generatedServerBase(c.baseURI), deviceID)
} else {
req, err = generatedhttp.NewTurnFloodlightOffRequest(generatedServerBase(c.baseURI), deviceID)
}
if err != nil {
return ringerrors.NewNetworkError("failed to build light request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
// SetMotionDetection shares the recorded typed settings PATCH route.
func (c *Client) SetMotionDetection(ctx context.Context, deviceID int64, enabled bool) error {
return c.PatchMotionDetectionEnabled(ctx, deviceID, enabled)
}
// TestSound uses the Python legacy chime route and query parameter.
func (c *Client) TestSound(ctx context.Context, deviceID int64, kind generatedhttp.TestChimeSoundParamsKind) error {
params := &generatedhttp.TestChimeSoundParams{Kind: kind}
request, err := generatedhttp.NewTestChimeSoundRequest(generatedServerBase(c.baseURI), deviceID, params)
if err != nil {
return ringerrors.NewNetworkError("failed to build chime sound request", err)
}
return c.doGeneratedJSON(ctx, request, nil)
}
// SetInHomeChimeType changes the legacy type field.
func (c *Client) SetInHomeChimeType(ctx context.Context, deviceID int64, description string, value int) error {
return c.updateLegacyDoorbotControls(ctx, deviceID, &generatedhttp.UpdateLegacyDoorbotControlsParams{
DoorbotDescription: description,
DoorbotSettingsDoorbellVolume: nil,
DoorbotSettingsChimeSettingsType: &value,
DoorbotSettingsChimeSettingsEnable: nil,
DoorbotSettingsChimeSettingsDuration: nil,
})
}
// SetInHomeChimeDuration changes the legacy duration field.
func (c *Client) SetInHomeChimeDuration(ctx context.Context, deviceID int64, description string, value int) error {
return c.updateLegacyDoorbotControls(ctx, deviceID, &generatedhttp.UpdateLegacyDoorbotControlsParams{
DoorbotDescription: description,
DoorbotSettingsDoorbellVolume: nil,
DoorbotSettingsChimeSettingsType: nil,
DoorbotSettingsChimeSettingsEnable: nil,
DoorbotSettingsChimeSettingsDuration: &value,
})
}
// SetInHomeChimeEnabled changes the legacy enable field.
func (c *Client) SetInHomeChimeEnabled(ctx context.Context, deviceID int64, description string, enabled bool) error {
value := generatedhttp.UpdateLegacyDoorbotControlsParamsDoorbotSettingsChimeSettingsEnable(0)
if enabled {
value = generatedhttp.UpdateLegacyDoorbotControlsParamsDoorbotSettingsChimeSettingsEnable(1)
}
return c.updateLegacyDoorbotControls(ctx, deviceID, &generatedhttp.UpdateLegacyDoorbotControlsParams{
DoorbotDescription: description,
DoorbotSettingsDoorbellVolume: nil,
DoorbotSettingsChimeSettingsType: nil,
DoorbotSettingsChimeSettingsEnable: &value,
DoorbotSettingsChimeSettingsDuration: nil,
})
}
func (c *Client) updateLegacyDoorbotControls(
ctx context.Context,
deviceID int64,
params *generatedhttp.UpdateLegacyDoorbotControlsParams,
) error {
request, err := generatedhttp.NewUpdateLegacyDoorbotControlsRequest(generatedServerBase(c.baseURI), deviceID, params)
if err != nil {
return ringerrors.NewNetworkError("failed to build legacy doorbot controls request", err)
}
return c.doGeneratedJSON(ctx, request, nil)
}
package rest
import (
"context"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// GetDevices retrieves all devices from the Ring API.
func (c *Client) GetDevices(ctx context.Context) (*generatedhttp.DeviceList, error) {
var raw generatedhttp.DeviceList
req, err := generatedhttp.NewListDevicesRequest(generatedServerBase(c.baseURI))
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build list devices request", err)
}
err = c.doGeneratedJSON(ctx, req, &raw)
if err != nil {
return nil, err
}
return &raw, nil
}
// RegisterSession registers the client hardware ID with Ring before API use.
func (c *Client) RegisterSession(ctx context.Context) error {
body := generatedhttp.ClientSessionRegistration{
Device: generatedhttp.ClientSessionRegistration_Device{
HardwareId: c.hardwareIDFor(ctx),
Metadata: generatedhttp.ClientSessionRegistration_Device_Metadata{
ApiVersion: generatedhttp.N11,
DeviceModel: deviceModel,
AdditionalProperties: nil,
},
AdditionalProperties: nil,
Os: generatedhttp.ClientSessionRegistrationDeviceOsAndroid,
},
AdditionalProperties: nil,
}
req, err := generatedhttp.NewRegisterClientSessionRequest(generatedServerBase(c.baseURI), body)
if err != nil {
return ringerrors.NewNetworkError("failed to build client session request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
// GetDeviceHealth retrieves health data for a specific device.
func (c *Client) GetDeviceHealth(ctx context.Context, deviceID int64) (*generatedhttp.LegacyDeviceHealth, error) {
var health generatedhttp.LegacyDeviceHealth
req, err := generatedhttp.NewGetLegacyDeviceHealthRequest(generatedServerBase(c.baseURI), deviceID)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build device health request", err)
}
err = c.doGeneratedJSON(ctx, req, &health)
if err != nil {
return nil, err
}
return &health, nil
}
package rest
import (
"context"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// RegisterPushDevice binds an FCM token to the authenticated Ring account.
func (c *Client) RegisterPushDevice(ctx context.Context, token string) error {
model := deviceModel
version := int(generatedhttp.N11)
body := generatedhttp.PushDeviceRegistration{Device: generatedhttp.PushDeviceRegistrationDevice{
Metadata: generatedhttp.PushDeviceMetadata{
ApiVersion: &version, DeviceModel: &model,
PnDictVersion: generatedhttp.N200, PnService: generatedhttp.Fcm,
},
Os: generatedhttp.PushDeviceRegistrationDeviceOsAndroid,
PushNotificationToken: token,
}}
req, err := generatedhttp.NewRegisterPushDeviceRequest(generatedServerBase(c.baseURI), body)
if err != nil {
return ringerrors.NewNetworkError("failed to build push registration request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
func (c *Client) SubscribeDeviceDing(ctx context.Context, id int64) error {
req, err := generatedhttp.NewSubscribeDeviceDingRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return ringerrors.NewNetworkError("failed to build ding subscription request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
func (c *Client) SubscribeDeviceMotion(ctx context.Context, id int64) error {
req, err := generatedhttp.NewSubscribeDeviceMotionRequest(generatedServerBase(c.baseURI), id)
if err != nil {
return ringerrors.NewNetworkError("failed to build motion subscription request", err)
}
return c.doGeneratedJSON(ctx, req, nil)
}
package rest
import (
"context"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
"github.com/portpowered/go-ring/internal/ringmedia"
)
// GetRecordingShareURL follows the pinned Python legacy share/play profile.
// The C1 recording does not establish this route's response.
func (c *Client) GetRecordingShareURL(ctx context.Context, recordingID int64) (string, error) {
var response generatedhttp.RecordingShare
req, err := generatedhttp.NewGetLegacyRecordingShareURLRequest(generatedServerBase(c.baseURI), recordingID)
if err != nil {
return "", ringerrors.NewNetworkError("failed to build recording share request", err)
}
err = c.doGeneratedJSON(ctx, req, &response)
if err != nil {
return "", err
}
if response.Url == "" {
return "", ringerrors.NewInternalServerError("recording share response lacks URL", nil)
}
return response.Url, nil
}
// GetDeviceHistory retrieves the history of recordings for a device.
func (c *Client) GetDeviceHistory(
ctx context.Context,
deviceID int64,
limit int,
kind string,
olderThan *int64,
) (generatedhttp.RecordingArray, error) {
params := &generatedhttp.GetLegacyDeviceHistoryParams{Limit: nil, Kind: nil, OlderThan: olderThan}
if limit > 0 {
params.Limit = &limit
}
if kind != "" {
params.Kind = &kind
}
// The API returns an array directly, not wrapped in an object
var recordings generatedhttp.RecordingArray
req, err := generatedhttp.NewGetLegacyDeviceHistoryRequest(generatedServerBase(c.baseURI), deviceID, params)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build device history request", err)
}
err = c.doGeneratedJSON(ctx, req, &recordings)
if err != nil {
return nil, err
}
return recordings, nil
}
// GetActiveDings retrieves currently active dings.
func (c *Client) GetActiveDings(ctx context.Context) (generatedhttp.RecordingArray, error) {
// The API returns an array directly, not wrapped in an object
var recordings generatedhttp.RecordingArray
req, err := generatedhttp.NewGetActiveDingsRequest(generatedServerBase(c.baseURI))
if err != nil {
return nil, ringerrors.NewNetworkError("failed to build active dings request", err)
}
err = c.doGeneratedJSON(ctx, req, &recordings)
if err != nil {
return nil, err
}
return recordings, nil
}
// GetRecording retrieves a video stream for a recording
// The endpoint returns video/mp4 directly in the response body.
func (c *Client) GetRecording(ctx context.Context, recordingID int64) (*ringmedia.VideoStream, error) {
// Build the streaming request from the OpenAPI operation. Keep the response
// body open so callers can stream the media instead of buffering it.
req, err := generatedhttp.NewStreamRecordingRequest(generatedServerBase(c.baseURI), recordingID)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to create request", err)
}
req = req.WithContext(ctx)
// Get token and set authorization header
token, err := c.getToken(ctx)
if err != nil {
return nil, ringerrors.NewTokenError("failed to get recording token", err)
}
req.Header.Set(string(generatedhttp.Authorization), "Bearer "+token)
// Don't set JSON headers for video requests - accept video/mp4
req.Header.Set(string(generatedhttp.Accept), "video/mp4,*/*")
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, ringerrors.NewNetworkError("failed to get recording", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_ = resp.Body.Close()
return nil, ringerrors.ClassifyHTTPError(resp, "")
}
// Extract content length from header if available
var contentLen int64 = -1
if resp.ContentLength > 0 {
contentLen = resp.ContentLength
}
stream := &ringmedia.VideoStream{
Body: resp.Body,
ContentType: resp.Header.Get("Content-Type"),
ContentLen: contentLen,
Headers: resp.Header,
}
return stream, nil
}
package rest
import (
"context"
"fmt"
"io"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/ringerrors"
)
const maxSnapshotBytes = 16 << 20
// RefreshSnapshotTimestamp uses the pinned Python legacy profile. The first
// response may have no timestamp; callers trigger once, then poll for freshness.
func (c *Client) RefreshSnapshotTimestamp(ctx context.Context, deviceID int64) (int64, error) {
var response generatedhttp.SnapshotTimestampResponse
req, err := generatedhttp.NewRefreshLegacySnapshotTimestampRequest(
generatedServerBase(c.baseURI), generatedhttp.SnapshotTimestampRequest{DoorbotIds: []int{int(deviceID)}},
)
if err != nil {
return 0, ringerrors.NewNetworkError("failed to build snapshot refresh request", err)
}
err = c.doGeneratedJSON(ctx, req, &response)
if err != nil {
return 0, err
}
if response.Timestamps == nil || len(*response.Timestamps) == 0 {
return 0, nil
}
return (*response.Timestamps)[0].Timestamp, nil
}
// GetSnapshotImage buffers one bounded JPEG response so the caller owns plain
// bytes rather than a live transport body.
func (c *Client) GetSnapshotImage(ctx context.Context, deviceID int64) ([]byte, string, error) {
req, err := generatedhttp.NewGetLegacySnapshotImageRequest(generatedServerBase(c.baseURI), deviceID)
if err != nil {
return nil, "", ringerrors.NewNetworkError("failed to create snapshot request", err)
}
req = req.WithContext(ctx)
token, err := c.getToken(ctx)
if err != nil {
return nil, "", ringerrors.NewTokenError("failed to get snapshot token", err)
}
req.Header.Set(string(generatedhttp.Authorization), "Bearer "+token)
req.Header.Set(string(generatedhttp.Accept), "image/jpeg")
req.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
req.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", ringerrors.NewNetworkError("failed to get snapshot image", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", ringerrors.ClassifyHTTPError(resp, "")
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSnapshotBytes+1))
if err != nil {
return nil, "", ringerrors.NewNetworkError("failed to read snapshot image", err)
}
if len(data) > maxSnapshotBytes {
return nil, "", ringerrors.NewInternalServerError(
fmt.Sprintf("snapshot exceeds %d bytes", maxSnapshotBytes),
nil,
)
}
return data, resp.Header.Get("Content-Type"), nil
}
// Package webrtc validates captured SDP and ICE media identities.
package webrtc
import (
"strings"
"github.com/pion/sdp/v3"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
)
const maxSDPBytes = 1 << 20
// ParseSDP validates the media identities used to route trickled ICE. It does not
// certify codec interoperability or replace a peer connection's SDP validation.
func ParseSDP(raw string) (*sdp.SessionDescription, error) {
if len(raw) > maxSDPBytes {
return nil, ringerrors.NewBadRequestError("SDP exceeds size limit", nil)
}
var description sdp.SessionDescription
// The recorded Android offers omit a final line ending. Pion's parser
// requires one; normalize only this framing detail before parsing.
if !strings.HasSuffix(raw, "\n") {
raw += "\r\n"
}
err := description.UnmarshalString(raw)
if err != nil {
return nil, ringerrors.NewBadRequestError("invalid SDP syntax", err)
}
if len(description.MediaDescriptions) == 0 {
return nil, ringerrors.NewBadRequestError("SDP has no media sections", nil)
}
sessionDirectionErr := validateSessionDirection(description.Attributes)
if sessionDirectionErr != nil {
return nil, sessionDirectionErr
}
mids, err := validateMediaSections(description.MediaDescriptions)
if err != nil {
return nil, err
}
bundleErr := validateBundleGroups(description.Attributes, mids)
if bundleErr != nil {
return nil, bundleErr
}
return &description, nil
}
func validateSessionDirection(attributes []sdp.Attribute) error {
directions := 0
for _, attribute := range attributes {
if isDirection(attribute.Key) {
directions++
}
}
if directions > 1 {
return ringerrors.NewBadRequestError("SDP has conflicting session directions", nil)
}
return nil
}
func validateMediaSections(mediaSections []*sdp.MediaDescription) (map[string]bool, error) {
mids := make(map[string]bool, len(mediaSections))
for _, media := range mediaSections {
mid, ok := media.Attribute("mid")
if !ok || mid == "" || mids[mid] {
return nil, ringerrors.NewBadRequestError("SDP requires unique media IDs", nil)
}
mids[mid] = true
attributesErr := validateMediaAttributes(media.Attributes)
if attributesErr != nil {
return nil, attributesErr
}
}
return mids, nil
}
func validateMediaAttributes(attributes []sdp.Attribute) error {
directions, mediaIDs := 0, 0
for _, attribute := range attributes {
if attribute.Key == "mid" {
mediaIDs++
}
if isDirection(attribute.Key) {
directions++
}
}
if mediaIDs != 1 {
return ringerrors.NewBadRequestError("SDP section must have exactly one media ID", nil)
}
if directions > 1 {
return ringerrors.NewBadRequestError("SDP has conflicting media directions", nil)
}
return nil
}
func validateBundleGroups(attributes []sdp.Attribute, mids map[string]bool) error {
for _, attribute := range attributes {
if attribute.Key != "group" {
continue
}
fields := strings.Fields(attribute.Value)
if len(fields) == 0 || fields[0] != "BUNDLE" {
continue
}
seen := make(map[string]bool)
for _, mid := range fields[1:] {
if !mids[mid] || seen[mid] {
return ringerrors.NewBadRequestError("SDP bundle references invalid media ID", nil)
}
seen[mid] = true
}
}
return nil
}
func isDirection(attribute string) bool {
switch attribute {
case protocol.SDPSendRecv, protocol.SDPSendOnly, protocol.SDPRecvOnly, protocol.SDPInactive:
return true
default:
return false
}
}
func direction(description *sdp.SessionDescription, media *sdp.MediaDescription) string {
for _, attributes := range [][]sdp.Attribute{media.Attributes, description.Attributes} {
for _, attribute := range attributes {
switch attribute.Key {
case protocol.SDPSendRecv, protocol.SDPSendOnly, protocol.SDPRecvOnly, protocol.SDPInactive:
return attribute.Key
}
}
}
return protocol.SDPSendRecv
}
// NormalizeAnswer applies the observed recvonly/sendrecv workaround by MID,
// never by media kind. Other attributes, including vendor extensions, survive.
func NormalizeAnswer(offer, answer string) (string, error) {
offerDescription, err := ParseSDP(offer)
if err != nil {
return "", err
}
answerDescription, err := ParseSDP(answer)
if err != nil {
return "", err
}
if len(offerDescription.MediaDescriptions) != len(answerDescription.MediaDescriptions) {
return "", ringerrors.NewBadRequestError("SDP answer media count differs", nil)
}
changed, err := normalizeAnswerMediaSections(offerDescription, answerDescription)
if err != nil {
return "", err
}
if !changed {
return answer, nil
}
encoded, err := answerDescription.Marshal()
if err != nil {
return "", ringerrors.NewInternalServerError("cannot encode SDP answer", err)
}
return string(encoded), nil
}
func normalizeAnswerMediaSections(
offer *sdp.SessionDescription,
answer *sdp.SessionDescription,
) (bool, error) {
changed := false
for index, answerMedia := range answer.MediaDescriptions {
offerMedia := offer.MediaDescriptions[index]
if !sameMediaIdentity(offerMedia, answerMedia) {
return false, ringerrors.NewBadRequestError("SDP answer media order differs", nil)
}
if answerMedia.MediaName.Port.Value == 0 {
continue
}
if offerMedia.MediaName.Port.Value == 0 {
return false, ringerrors.NewBadRequestError("SDP answer reactivates a rejected offer section", nil)
}
changed = normalizeRecvOnlyAnswer(offer, answer, offerMedia, answerMedia) || changed
if !answerDirectionAllowed(offer, answer, offerMedia, answerMedia) {
return false, ringerrors.NewBadRequestError("SDP answer direction incompatible with offer", nil)
}
}
return changed, nil
}
func sameMediaIdentity(offer, answer *sdp.MediaDescription) bool {
offeredMID, _ := offer.Attribute("mid")
answerMID, _ := answer.Attribute("mid")
return answerMID == offeredMID && answer.MediaName.Media == offer.MediaName.Media
}
func normalizeRecvOnlyAnswer(
offerDescription *sdp.SessionDescription,
answerDescription *sdp.SessionDescription,
offer, answer *sdp.MediaDescription,
) bool {
if direction(offerDescription, offer) != protocol.SDPRecvOnly ||
direction(answerDescription, answer) != protocol.SDPSendRecv {
return false
}
found := false
for index := range answer.Attributes {
if answer.Attributes[index].Key == protocol.SDPSendRecv {
answer.Attributes[index].Key = protocol.SDPSendOnly
found = true
}
}
if !found {
answer.Attributes = append(answer.Attributes, sdp.NewPropertyAttribute(protocol.SDPSendOnly))
}
return true
}
func answerDirectionAllowed(
offerDescription *sdp.SessionDescription,
answerDescription *sdp.SessionDescription,
offer, answer *sdp.MediaDescription,
) bool {
offerDirection := direction(offerDescription, offer)
answerDirection := direction(answerDescription, answer)
if offerDirection == protocol.SDPRecvOnly {
return answerDirection == protocol.SDPSendOnly || answerDirection == protocol.SDPInactive
}
if offerDirection == protocol.SDPSendOnly {
return answerDirection == protocol.SDPRecvOnly || answerDirection == protocol.SDPInactive
}
return offerDirection != protocol.SDPInactive || answerDirection == protocol.SDPInactive
}
// ValidateICE checks that the candidate's optional MID and index refer to the
// same media section. Candidate grammar remains the peer implementation's job.
func ValidateICE(description *sdp.SessionDescription, mid string, index int) error {
if description == nil || index < 0 || index >= len(description.MediaDescriptions) {
return ringerrors.NewBadRequestError("ICE media index out of range", nil)
}
expected, _ := description.MediaDescriptions[index].Attribute("mid")
if mid != "" && mid != expected {
return ringerrors.NewBadRequestError("ICE media ID and index differ", nil)
}
return nil
}
package websocket
import (
"context"
"encoding/json"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
)
type accountEventRecord struct {
frame generatedsignaling.AccountEventFrame
raw json.RawMessage
}
// EventConnection owns the transport, read loop, and event queue.
type EventConnection struct {
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
// receiveMu serializes consumers so events cannot overtake a terminal read error.
receiveMu sync.Mutex
closed bool
// pendingErr retains a consumed terminal error until queued events are drained.
pendingErr error
messageChan chan accountEventRecord
errChan chan error
}
// OpenEvents connects the account event stream and starts its read loop.
func OpenEvents(ctx context.Context, wsURL, token, hardwareID string) (*EventConnection, error) {
return OpenEventsWithDialer(ctx, wsURL, token, hardwareID, nil)
}
// OpenEventsWithDialer connects the account event stream using the supplied dialer.
// A nil dialer uses Gorilla's default dialer with a bounded handshake timeout.
func OpenEventsWithDialer(
ctx context.Context,
wsURL, token, hardwareID string,
dialer Dialer,
) (*EventConnection, error) {
parsed, _ := url.Parse(wsURL)
var validationErr error
if parsed != nil && parsed.Hostname() == "api.ring.com" {
validationErr = protocol.ValidateWebSocketURL(protocol.AccountEventsChannel, wsURL)
} else {
validationErr = protocol.ValidateWebSocketOverride(wsURL)
}
if validationErr != nil {
return nil, ringerrors.NewBadRequestError("invalid account event WebSocket URL", validationErr)
}
header := http.Header{}
header.Set(string(generatedhttp.Authorization), "Bearer "+token)
if hardwareID != "" {
header.Set(string(generatedhttp.HardwareId), hardwareID)
}
if dialer == nil {
dialer = &websocket.Dialer{HandshakeTimeout: 10 * time.Second}
}
conn, response, err := dialer.DialContext(ctx, wsURL, header)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
return nil, ringerrors.NewNetworkError("event websocket dial failed", err)
}
eventCtx, cancel := context.WithCancel(ctx)
ec := &EventConnection{
conn: conn,
ctx: eventCtx,
cancel: cancel,
messageChan: make(chan accountEventRecord, 100),
errChan: make(chan error, 10),
}
ec.wg.Add(1)
go ec.processMessages()
ec.wg.Add(1)
go func() {
defer ec.wg.Done()
<-eventCtx.Done()
_ = conn.Close()
}()
return ec, nil
}
// Receive receives events in wire order before a terminal read error.
// Concurrent callers are serialized to preserve that ordering.
func (ec *EventConnection) Receive() (map[string]interface{}, error) {
event, err := ec.receiveRecord()
if err != nil {
return nil, err
}
return decodeAccountEventPayload(event.raw)
}
// ReceiveFrame returns the schema-generated event projection and its full raw payload.
func (ec *EventConnection) ReceiveFrame() (generatedsignaling.AccountEventFrame, json.RawMessage, error) {
event, err := ec.receiveRecord()
if err != nil {
return generatedsignaling.AccountEventFrame{}, nil, err
}
return event.frame, append(json.RawMessage(nil), event.raw...), nil
}
func decodeAccountEventPayload(raw json.RawMessage) (map[string]interface{}, error) {
var payload map[string]interface{}
err := json.Unmarshal(raw, &payload)
if err != nil {
return nil, ringerrors.NewConnectionError("invalid account event payload", err)
}
if payload == nil {
return nil, ringerrors.NewConnectionError("account event payload must be an object", nil)
}
return payload, nil
}
// Close closes the event connection.
func (ec *EventConnection) Close() error {
ec.mu.Lock()
if ec.closed {
ec.mu.Unlock()
return nil
}
ec.closed = true
ec.mu.Unlock()
ec.cancel()
if ec.conn != nil {
_ = ec.conn.Close()
}
ec.wg.Wait()
close(ec.messageChan)
close(ec.errChan)
return nil
}
func (ec *EventConnection) receiveRecord() (accountEventRecord, error) {
ec.receiveMu.Lock()
defer ec.receiveMu.Unlock()
ec.mu.RLock()
closed := ec.closed
ec.mu.RUnlock()
if closed {
return accountEventRecord{}, ringerrors.NewClosedError("connection is closed")
}
if ec.pendingErr != nil {
select {
case event, ok := <-ec.messageChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("message channel closed")
}
return event, nil
default:
}
pendingErr := ec.pendingErr
ec.pendingErr = nil
return accountEventRecord{}, pendingErr
}
// Preserve wire order by draining already-decoded events before returning
// the terminal read error that follows them.
select {
case event, ok := <-ec.messageChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("message channel closed")
}
return event, nil
default:
}
select {
case event, ok := <-ec.messageChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("message channel closed")
}
return event, nil
case err, ok := <-ec.errChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("error channel closed")
}
return ec.prioritizePendingEvent(err)
case <-ec.ctx.Done():
// A peer read failure queues its error before cancelling the context.
// If both are ready, report the failure rather than a generic close.
select {
case err, ok := <-ec.errChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("error channel closed")
}
return ec.prioritizePendingEvent(err)
default:
}
select {
case event, ok := <-ec.messageChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("message channel closed")
}
return event, nil
default:
}
return accountEventRecord{}, ringerrors.NewClosedError("context cancelled")
}
}
func (ec *EventConnection) prioritizePendingEvent(readErr error) (accountEventRecord, error) {
select {
case event, ok := <-ec.messageChan:
if !ok {
return accountEventRecord{}, ringerrors.NewClosedError("message channel closed")
}
ec.pendingErr = readErr
return event, nil
default:
return accountEventRecord{}, readErr
}
}
// processMessages processes incoming WebSocket messages.
func (ec *EventConnection) processMessages() {
defer ec.wg.Done()
defer func() { _ = ec.conn.Close() }()
defer ec.cancel()
for {
select {
case <-ec.ctx.Done():
return
default:
_, message, err := ec.conn.ReadMessage()
if err != nil {
select {
case ec.errChan <- ringerrors.NewConnectionError("failed to read message", err):
default:
}
return
}
var frame generatedsignaling.AccountEventFrame
err = json.Unmarshal(message, &frame)
if err != nil {
continue
}
select {
case ec.messageChan <- accountEventRecord{
frame: frame,
raw: append(json.RawMessage(nil), message...),
}:
case <-ec.ctx.Done():
return
}
}
}
}
package websocket
import (
"context"
"encoding/json"
"time"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
"github.com/portpowered/go-ring/internal/signaling"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
)
// LiveNegotiation contains the identities and timing returned by signaling.
type LiveNegotiation struct {
SignalID string
RIID string
AnswerSDP string
ControlID string
Heartbeat time.Duration
}
// AwaitLiveAnswer pairs session_created with the SDP answer and validates their identities.
func AwaitLiveAnswer(
ctx context.Context,
parentDone <-chan struct{},
parentErr func() error,
events <-chan signaling.Message,
deviceID int64,
deadlineError func() error,
) (LiveNegotiation, error) {
state := LiveNegotiation{
SignalID: "",
RIID: "",
AnswerSDP: "",
ControlID: "",
Heartbeat: signaling.DefaultHeartbeatInterval,
}
created := false
for state.AnswerSDP == "" || state.SignalID == "" {
select {
case message := <-events:
nextState, nextCreated, err := acceptLiveNegotiationMessage(state, created, message, deviceID)
if err != nil {
return nextState, err
}
state, created = nextState, nextCreated
case <-ctx.Done():
return state, ringerrors.NewConnectionError("signaling negotiation failed", deadlineError())
case <-parentDone:
return state, parentErr()
}
}
return state, nil
}
func acceptLiveNegotiationMessage(
state LiveNegotiation,
created bool,
message signaling.Message,
deviceID int64,
) (LiveNegotiation, bool, error) {
switch message.Method {
case protocol.MethodSessionCreated:
return acceptSessionCreated(state, created, message, deviceID)
case protocol.MethodSDP:
if !created {
return state, created, ringerrors.NewConnectionError("SDP answer before session_created", nil)
}
state, err := acceptLiveAnswer(state, message, deviceID)
return state, created, err
case protocol.MethodClose:
return state, created, ringerrors.NewClosedError("signaling peer closed during negotiation")
default:
return state, created, nil
}
}
func acceptSessionCreated(
state LiveNegotiation,
created bool,
message signaling.Message,
deviceID int64,
) (LiveNegotiation, bool, error) {
var body generatedsignaling.SessionCreatedBody
err := json.Unmarshal(message.Body, &body)
if err != nil {
return state, created, ringerrors.NewConnectionError("invalid session_created response", err)
}
if int64(body.DoorbotId) != deviceID || body.SessionId == "" {
return state, created, ringerrors.NewConnectionError("invalid session_created response", nil)
}
if created && (body.SessionId != state.SignalID ||
(message.RIID != "" && state.RIID != "" && message.RIID != state.RIID)) {
return state, created, ringerrors.NewConnectionError("conflicting session_created response", nil)
}
state.SignalID = body.SessionId
if message.RIID != "" {
state.RIID = message.RIID
}
return state, true, nil
}
func acceptLiveAnswer(state LiveNegotiation, message signaling.Message, deviceID int64) (LiveNegotiation, error) {
var (
envelope map[string]json.RawMessage
info map[string]json.RawMessage
)
err := json.Unmarshal(message.Body, &envelope)
if err != nil {
return state, ringerrors.NewConnectionError("invalid SDP answer", err)
}
err = json.Unmarshal(envelope[protocol.FieldSessionInfo], &info)
if err != nil {
return state, ringerrors.NewConnectionError("invalid SDP answer", err)
}
if raw, present := info[protocol.FieldPingInterval]; present {
var seconds int
err := json.Unmarshal(raw, &seconds)
if err != nil {
return state, ringerrors.NewConnectionError("invalid negotiated heartbeat interval", err)
}
if seconds <= 0 || seconds > int(signaling.MaxHeartbeatInterval/time.Second) {
return state, ringerrors.NewConnectionError("invalid negotiated heartbeat interval", nil)
}
state.Heartbeat = time.Duration(seconds) * time.Second
}
var body generatedsignaling.LiveAnswerBody
err = json.Unmarshal(message.Body, &body)
if err != nil {
return state, ringerrors.NewConnectionError("invalid SDP answer", err)
}
if int64(body.DoorbotId) != deviceID || body.ReservedType != protocol.SDPTypeAnswer || body.Sdp == "" ||
body.SessionInfo == nil {
return state, ringerrors.NewConnectionError("invalid SDP answer", nil)
}
if state.SignalID != "" && body.SessionId != state.SignalID {
return state, ringerrors.NewConnectionError("SDP signaling session mismatch", nil)
}
state.SignalID, state.AnswerSDP, state.ControlID = body.SessionId, body.Sdp, body.SessionInfo.SessionId
if message.RIID != "" {
state.RIID = message.RIID
}
return state, nil
}
package websocket
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
"github.com/portpowered/go-ring/internal/signaling"
)
// Dialer allows tests and callers to substitute a WebSocket transport.
type Dialer interface {
DialContext(ctx context.Context, wsURL string, headers http.Header) (*websocket.Conn, *http.Response, error)
}
// DialSignaling opens a bounded signaling socket with an optional custom dialer.
func DialSignaling(ctx context.Context, wsURL string, headers http.Header, dialer Dialer) (*websocket.Conn, error) {
err := validateSignalingDialURL(wsURL)
if err != nil {
return nil, ringerrors.NewBadRequestError("invalid signaling WebSocket URL", err)
}
if dialer == nil {
dialer = &websocket.Dialer{HandshakeTimeout: signaling.HandshakeTimeout}
}
conn, response, err := dialer.DialContext(ctx, wsURL, headers)
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
return nil, ringerrors.NewNetworkError("signaling websocket dial failed", err)
}
if conn == nil {
return nil, ringerrors.NewConnectionError("signaling dialer returned an empty connection", nil)
}
conn.SetReadLimit(signaling.MaxMessageBytes)
return conn, nil
}
func validateSignalingDialURL(wsURL string) error {
parsed, err := url.Parse(wsURL)
if err != nil {
return ringerrors.NewBadRequestError("parse signaling WebSocket URL", err)
}
isModeledOrigin := strings.EqualFold(parsed.Scheme, "wss") &&
strings.EqualFold(parsed.Hostname(), "api.prod.signalling.ring.devices.a2z.com") &&
(parsed.Port() == "" || parsed.Port() == "443") && parsed.EscapedPath() == "/ws"
if isModeledOrigin {
err = protocol.ValidateWebSocketURL(protocol.SignalingChannel, wsURL)
if err != nil {
return ringerrors.NewBadRequestError("validate modeled signaling WebSocket URL", err)
}
return nil
}
err = protocol.ValidateWebSocketOverride(wsURL)
if err != nil {
return ringerrors.NewBadRequestError("validate signaling WebSocket override", err)
}
return nil
}
// WriteSignaling sends one JSON frame with a context-sensitive write deadline.
func WriteSignaling(ctx context.Context, conn *websocket.Conn, message signaling.Message) error {
{
err := ctx.Err()
if err != nil {
return wrapSignalingContextError(err)
}
}
deadline := time.Now().Add(signaling.SendTimeout)
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
netConn := conn.NetConn()
// Gorilla reapplies its cached deadline inside WriteMessage.
_ = conn.SetWriteDeadline(deadline)
_ = netConn.SetWriteDeadline(deadline)
cancelWriteDone := make(chan struct{})
stopCancel := context.AfterFunc(ctx, func() { _ = netConn.SetWriteDeadline(time.Now()); close(cancelWriteDone) })
encoded, writeErr := marshalSignalingFrame(message)
encodingFailed := writeErr != nil
if !encodingFailed {
writeErr = conn.WriteMessage(websocket.TextMessage, encoded)
}
if !stopCancel() {
<-cancelWriteDone
}
_ = conn.SetWriteDeadline(time.Time{})
_ = netConn.SetWriteDeadline(time.Time{})
if writeErr != nil {
cause := ctx.Err()
if cause != nil {
return wrapSignalingContextError(cause)
}
var timeout net.Error
if d, ok := ctx.Deadline(); ok && !time.Now().Before(d) && errors.As(writeErr, &timeout) && timeout.Timeout() {
return wrapSignalingContextError(context.DeadlineExceeded)
}
if encodingFailed {
return ringerrors.NewBadRequestError("encode signaling message", writeErr)
}
return ringerrors.NewConnectionError("write signaling message", writeErr)
}
return nil
}
func wrapSignalingContextError(cause error) error {
if cause == nil {
return nil
}
return ringerrors.NewConnectionError("signaling write canceled", cause)
}
// ReadSignaling reads text frames until a transport or decoding error occurs.
func ReadSignaling(conn *websocket.Conn, route func(signaling.Message)) error {
for {
typ, encoded, err := conn.ReadMessage()
if err != nil {
return ringerrors.NewConnectionError("signaling read failed", err)
}
if typ != websocket.TextMessage {
continue
}
message, err := unmarshalSignalingFrame(encoded)
if err != nil {
return ringerrors.NewConnectionError("invalid signaling message", err)
}
route(message)
}
}
package websocket
import (
"bytes"
"encoding/json"
"fmt"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/ringerrors"
"github.com/portpowered/go-ring/internal/signaling"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
)
func unmarshalSignalingFrame(encoded []byte) (signaling.Message, error) {
var discriminator generatedsignaling.SignalingInboundDiscriminator
err := json.Unmarshal(encoded, &discriminator)
if err != nil {
return signaling.Message{}, wrapSignalingWireError(err, "decode generated signaling discriminator")
}
if discriminator.Method == nil || discriminator.Body == nil {
return signaling.Message{}, signalingWireError("signaling frame requires a recognized method and body")
}
fields, err := decodeEnvelopeFields(encoded)
if err != nil {
return signaling.Message{}, err
}
methodValue, ok := discriminator.Method.Value().(string)
if !ok || methodValue != fields.method {
return signaling.Message{}, signalingWireError("unknown signaling method %q", fields.method)
}
if fields.dialogID == "" || fields.body == nil {
return signaling.Message{}, signalingWireError("signaling frame requires dialog_id and body")
}
bodyFields, err := decodeBodyFields(fields.body)
if err != nil {
return signaling.Message{}, err
}
err = validateInboundFrame(encoded, fields, bodyFields)
if err != nil {
return signaling.Message{}, err
}
return signaling.Message{Method: fields.method, DialogID: fields.dialogID, RIID: fields.riid, Body: fields.body}, nil
}
type signalingEnvelopeFields struct {
method string
dialogID string
riid string
body json.RawMessage
}
func decodeEnvelopeFields(encoded []byte) (signalingEnvelopeFields, error) {
var values map[string]json.RawMessage
err := json.Unmarshal(encoded, &values)
if err != nil {
return signalingEnvelopeFields{}, wrapSignalingWireError(err, "decode signaling envelope fields")
}
if values == nil {
return signalingEnvelopeFields{}, signalingWireError("signaling envelope must be an object")
}
var result signalingEnvelopeFields
fields := map[string]*string{
protocol.FieldMethod: &result.method,
protocol.FieldDialogID: &result.dialogID,
protocol.FieldRIID: &result.riid,
}
for name, destination := range fields {
if raw, exists := values[name]; exists {
err := json.Unmarshal(raw, destination)
if err != nil {
return signalingEnvelopeFields{}, wrapSignalingWireError(err, "decode signaling envelope %s", name)
}
}
}
result.body = values[protocol.FieldBody]
return result, nil
}
func decodeBodyFields(encoded json.RawMessage) (map[string]json.RawMessage, error) {
var fields map[string]json.RawMessage
err := json.Unmarshal(encoded, &fields)
if err != nil {
return nil, wrapSignalingWireError(err, "decode signaling body")
}
if fields == nil {
return nil, signalingWireError("signaling body must be an object")
}
return fields, nil
}
func validateInboundFrame(encoded []byte, envelope signalingEnvelopeFields, body map[string]json.RawMessage) error {
switch envelope.method {
case protocol.MethodCameraStarted:
return validateTypedInbound[generatedsignaling.ServerCameraStartedFrame](encoded, envelope, body,
func(frame *generatedsignaling.ServerCameraStartedFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldSessionID)
case protocol.MethodClose:
return validateTypedInbound[generatedsignaling.ServerCloseFrame](encoded, envelope, body,
func(frame *generatedsignaling.ServerCloseFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
})
case protocol.MethodICE:
if _, hasMID := body[protocol.FieldMID]; hasMID {
return validateTypedInbound[generatedsignaling.LiveIceFrame](encoded, envelope, body,
func(frame *generatedsignaling.LiveIceFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldIce, protocol.FieldMID, protocol.FieldMLineIndex)
}
return validateTypedInbound[generatedsignaling.ServerIceFrame](encoded, envelope, body,
func(frame *generatedsignaling.ServerIceFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldIce, protocol.FieldMLineIndex)
case protocol.MethodNotification:
return validateTypedInbound[generatedsignaling.SessionNotificationFrame](encoded, envelope, body,
func(frame *generatedsignaling.SessionNotificationFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldIsOK, protocol.FieldText)
case protocol.MethodPong:
return validateTypedInbound[generatedsignaling.SessionPongFrame](encoded, envelope, body,
func(frame *generatedsignaling.SessionPongFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldSessionID)
case protocol.MethodPushEvent:
return validateTypedInbound[generatedsignaling.PushEventFrame](encoded, envelope, body,
func(frame *generatedsignaling.PushEventFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
},
protocol.FieldNotificationScope,
protocol.FieldNotificationType,
protocol.FieldPayload,
protocol.FieldSubscriptionID,
)
case protocol.MethodPushSubscriptionAck:
return validateTypedInbound[generatedsignaling.PushSubscriptionAckFrame](encoded, envelope, body,
func(frame *generatedsignaling.PushSubscriptionAckFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldStatus, protocol.FieldSubscriptionID)
case protocol.MethodRPC:
return validateTypedInbound[generatedsignaling.ServerRpcFrame](encoded, envelope, body,
func(frame *generatedsignaling.ServerRpcFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldCommand)
case protocol.MethodSDP:
if hasSessionIdentifier(body[protocol.FieldSessionInfo]) {
return validateTypedInbound[generatedsignaling.LiveAnswerFrame](encoded, envelope, body,
func(frame *generatedsignaling.LiveAnswerFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
},
protocol.FieldDeviceID,
protocol.FieldSessionID,
protocol.FieldSDP,
protocol.FieldSessionInfo,
protocol.FieldType,
)
}
return validateTypedInbound[generatedsignaling.PlaybackAnswerFrame](encoded, envelope, body,
func(frame *generatedsignaling.PlaybackAnswerFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldSDP, protocol.FieldType)
case protocol.MethodSessionCreated:
return validateTypedInbound[generatedsignaling.SessionCreatedFrame](encoded, envelope, body,
func(frame *generatedsignaling.SessionCreatedFrame) (string, string, bool) {
return frame.Method, frame.DialogId, frame.Body != nil
}, protocol.FieldDeviceID, protocol.FieldSessionID)
default:
return signalingWireError("unsupported inbound signaling method %q", envelope.method)
}
}
func validateTypedInbound[Frame any](
encoded []byte,
envelope signalingEnvelopeFields,
body map[string]json.RawMessage,
metadata func(*Frame) (string, string, bool),
required ...string,
) error {
decodeInput := encoded
if envelope.method == protocol.MethodClose {
normalized, err := normalizeCloseReasonCode(encoded)
if err != nil {
return err
}
decodeInput = normalized
}
var frame Frame
err := json.Unmarshal(decodeInput, &frame)
if err != nil {
return wrapSignalingWireError(err, "decode generated signaling frame for %q", envelope.method)
}
method, dialogID, hasBody := metadata(&frame)
if method != envelope.method || dialogID != envelope.dialogID || !hasBody {
return signalingWireError("signaling method %q does not match its generated frame shape", envelope.method)
}
for _, name := range required {
if _, exists := body[name]; !exists {
return signalingWireError("signaling body for %q is missing required field %q", envelope.method, name)
}
}
return nil
}
func hasSessionIdentifier(encoded json.RawMessage) bool {
if len(encoded) == 0 {
return false
}
fields, err := decodeBodyFields(encoded)
if err != nil {
return false
}
_, exists := fields[protocol.FieldSessionID]
return exists
}
func marshalSignalingFrame(message signaling.Message) ([]byte, error) {
switch message.Method {
case protocol.MethodActivateSession:
return marshalActivateSessionFrame(message)
case protocol.MethodClose:
return marshalCloseFrame(message)
case protocol.MethodICE:
return marshalICEFrame(message)
case protocol.MethodLiveView:
return marshalLiveViewFrame(message)
case protocol.MethodMicEnable:
return marshalMicEnableFrame(message)
case protocol.MethodPing:
return marshalPingFrame(message)
case protocol.MethodPlayback:
return marshalPlaybackFrame(message)
case protocol.MethodPushHeartbeat:
return marshalPushHeartbeatFrame(message)
case protocol.MethodPushSubscribe:
return marshalPushSubscribeFrame(message)
case protocol.MethodPushUnsubscribe:
return marshalPushUnsubscribeFrame(message)
case protocol.MethodRPC:
return marshalRPCFrame(message)
case protocol.MethodStreamOptions:
return marshalStreamOptions(message)
default:
return nil, signalingWireError("unknown or unsupported outbound signaling method %q", message.Method)
}
}
func marshalActivateSessionFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodActivateSession,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID},
func(body *generatedsignaling.SessionBody) any {
return generatedsignaling.SessionActivateFrame{
Method: protocol.MethodActivateSession, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalCloseFrame(message signaling.Message) ([]byte, error) {
if bodyHasProperty(message.Body, protocol.FieldReason) {
return marshalGeneratedFrame(
message,
protocol.MethodClose,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldReason},
func(body *generatedsignaling.PlaybackCloseBody) any {
return generatedsignaling.PlaybackCloseFrame{
Method: protocol.MethodClose, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
return marshalGeneratedFrame(
message,
protocol.MethodClose,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID},
func(body *generatedsignaling.SessionBody) any {
return generatedsignaling.SessionCloseFrame{
Method: protocol.MethodClose, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalICEFrame(message signaling.Message) ([]byte, error) {
if bodyHasProperty(message.Body, protocol.FieldMID) {
return marshalGeneratedFrame(
message,
protocol.MethodICE,
[]string{protocol.FieldDeviceID, protocol.FieldIce, protocol.FieldMID, protocol.FieldMLineIndex},
func(body *generatedsignaling.LiveIceBody) any {
return generatedsignaling.LiveIceFrame{
Method: protocol.MethodICE, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
return marshalGeneratedFrame(
message,
protocol.MethodICE,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldIce, protocol.FieldMLineIndex},
func(body *generatedsignaling.IceCandidateBody) any {
return generatedsignaling.IceCandidateFrame{
Method: protocol.MethodICE, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalLiveViewFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodLiveView,
[]string{protocol.FieldDeviceID, protocol.FieldSDP, protocol.FieldStreamOptions, protocol.FieldType},
func(body *generatedsignaling.LiveViewBody) any {
return generatedsignaling.LiveViewFrame{
Method: protocol.MethodLiveView, DialogId: message.DialogID,
Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalMicEnableFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodMicEnable,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldEnabled},
func(body *generatedsignaling.SessionMicrophoneBody) any {
return generatedsignaling.SessionMicrophoneFrame{
Method: protocol.MethodMicEnable, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalPingFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodPing,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID},
func(body *generatedsignaling.SessionBody) any {
return generatedsignaling.SessionPingFrame{
Method: protocol.MethodPing, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalPlaybackFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodPlayback,
[]string{protocol.FieldDeviceID, protocol.FieldEntryPoint, protocol.FieldSDP, protocol.FieldType},
func(body *generatedsignaling.PlaybackOfferBody) any {
return generatedsignaling.PlaybackOfferFrame{
Method: protocol.MethodPlayback, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalPushHeartbeatFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodPushHeartbeat,
[]string{protocol.FieldSubscriptionID},
func(body *generatedsignaling.PushSubscriptionBody) any {
return generatedsignaling.PushHeartbeatFrame{
Method: protocol.MethodPushHeartbeat, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalPushSubscribeFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodPushSubscribe,
[]string{protocol.FieldRequestedNotifications},
func(body *generatedsignaling.PushSubscribeBody) any {
return generatedsignaling.PushSubscribeFrame{
Method: protocol.MethodPushSubscribe, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalPushUnsubscribeFrame(message signaling.Message) ([]byte, error) {
return marshalGeneratedFrame(
message,
protocol.MethodPushUnsubscribe,
[]string{protocol.FieldSubscriptionID},
func(body *generatedsignaling.PushSubscriptionBody) any {
return generatedsignaling.PushUnsubscribeFrame{
Method: protocol.MethodPushUnsubscribe, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
}
func marshalRPCFrame(message signaling.Message) ([]byte, error) {
commandMethod, err := rpcCommandMethod(message.Body)
if err != nil {
return nil, err
}
switch commandMethod {
case protocol.RPCPanContinuous, protocol.RPCTiltContinuous:
err = validateRPCFields(
message.Body,
[]string{protocol.FieldJSONRPC, protocol.FieldRPCID, protocol.FieldMethod, protocol.FieldParams},
[]string{
protocol.FieldSessionIDRPC,
protocol.FieldTimestamp,
protocol.FieldVersion,
protocol.FieldDirection,
protocol.FieldSpeed,
},
)
if err != nil {
return nil, err
}
return marshalGeneratedFrame(
message,
protocol.MethodRPC,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldCommand},
func(body *generatedsignaling.PtzContinuousCommandBody) any {
return generatedsignaling.PtzContinuousCommandFrame{
Method: protocol.MethodRPC, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
case protocol.RPCPanStep, protocol.RPCTiltStep:
err = validateRPCFields(
message.Body,
[]string{protocol.FieldJSONRPC, protocol.FieldRPCID, protocol.FieldMethod, protocol.FieldParams},
[]string{protocol.FieldSessionIDRPC, protocol.FieldTimestamp, protocol.FieldVersion, protocol.FieldDirection},
)
if err != nil {
return nil, err
}
return marshalGeneratedFrame(
message,
protocol.MethodRPC,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldCommand},
func(body *generatedsignaling.PtzCommandBody) any {
return generatedsignaling.PtzCommandFrame{
Method: protocol.MethodRPC, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
default:
return nil, signalingWireError("unsupported PTZ RPC method %q", commandMethod)
}
}
func rpcCommandMethod(body json.RawMessage) (string, error) {
fields, err := decodeBodyFields(body)
if err != nil {
return "", err
}
commandRaw, exists := fields[protocol.FieldCommand]
if !exists {
return "", signalingWireError("PTZ RPC body is missing command")
}
command, err := decodeBodyFields(commandRaw)
if err != nil {
return "", err
}
methodRaw, exists := command[protocol.FieldMethod]
if !exists {
return "", signalingWireError("PTZ RPC command is missing method")
}
var method string
err = json.Unmarshal(methodRaw, &method)
if err != nil {
return "", wrapSignalingWireError(err, "decode PTZ RPC method")
}
return method, nil
}
func validateRPCFields(body json.RawMessage, commandRequired, paramsRequired []string) error {
fields, err := decodeBodyFields(body)
if err != nil {
return err
}
command, err := decodeBodyProperty(fields, protocol.FieldCommand)
if err != nil {
return err
}
err = requireJSONFields(command, commandRequired...)
if err != nil {
return wrapSignalingWireError(err, "PTZ RPC command")
}
params, err := decodeBodyProperty(command, protocol.FieldParams)
if err != nil {
return err
}
err = requireJSONFields(params, paramsRequired...)
if err != nil {
return wrapSignalingWireError(err, "PTZ RPC params")
}
return nil
}
func decodeBodyProperty(fields map[string]json.RawMessage, key string) (map[string]json.RawMessage, error) {
encoded, exists := fields[key]
if !exists {
return nil, signalingWireError("missing required field %q", key)
}
decoded, err := decodeBodyFields(encoded)
if err != nil {
return nil, wrapSignalingWireError(err, "decode field %q", key)
}
return decoded, nil
}
func requireJSONFields(fields map[string]json.RawMessage, required ...string) error {
for _, key := range required {
if _, exists := fields[key]; !exists {
return signalingWireError("missing required field %q", key)
}
}
return nil
}
func marshalStreamOptions(message signaling.Message) ([]byte, error) {
hasAudio := bodyHasProperty(message.Body, protocol.FieldAudioEnabled)
hasVideo := bodyHasProperty(message.Body, protocol.FieldVideoEnabled)
switch {
case hasAudio && hasVideo:
return marshalGeneratedFrame(
message,
protocol.MethodStreamOptions,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldAudioEnabled, protocol.FieldVideoEnabled},
func(body *generatedsignaling.SessionStreamAudioVideoOptionsBody) any {
return generatedsignaling.SessionStreamAudioVideoOptionsFrame{
Method: protocol.MethodStreamOptions, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
case hasAudio:
return marshalGeneratedFrame(
message,
protocol.MethodStreamOptions,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldAudioEnabled},
func(body *generatedsignaling.SessionStreamAudioOptionsBody) any {
return generatedsignaling.SessionStreamAudioOptionsFrame{
Method: protocol.MethodStreamOptions, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
case hasVideo:
return marshalGeneratedFrame(
message,
protocol.MethodStreamOptions,
[]string{protocol.FieldDeviceID, protocol.FieldSessionID, protocol.FieldVideoEnabled},
func(body *generatedsignaling.SessionStreamVideoOptionsBody) any {
return generatedsignaling.SessionStreamVideoOptionsFrame{
Method: protocol.MethodStreamOptions, DialogId: message.DialogID,
Riid: message.RIID, Body: body, AdditionalProperties: nil,
}
},
)
default:
return nil, signalingWireError("stream_options requires audio_enabled, video_enabled, or both")
}
}
func marshalGeneratedFrame[Body any](
message signaling.Message,
method string,
required []string,
build func(*Body) any,
) ([]byte, error) {
if message.Method != method || message.DialogID == "" {
return nil, signalingWireError("signaling frame method and dialog_id are required")
}
if len(bytes.TrimSpace(message.Body)) == 0 || bytes.Equal(bytes.TrimSpace(message.Body), []byte("null")) {
return nil, signalingWireError("signaling frame body is required")
}
var fields map[string]json.RawMessage
err := json.Unmarshal(message.Body, &fields)
if err != nil {
return nil, wrapSignalingWireError(err, "decode signaling body fields")
}
if fields == nil {
return nil, signalingWireError("signaling body must be an object")
}
for _, name := range required {
if _, exists := fields[name]; !exists {
return nil, signalingWireError("signaling body is missing required field %q", name)
}
}
var body Body
err = json.Unmarshal(message.Body, &body)
if err != nil {
return nil, wrapSignalingWireError(err, "decode generated signaling body")
}
encoded, err := json.Marshal(build(&body))
if err != nil {
return nil, wrapSignalingWireError(err, "encode generated signaling frame")
}
return encoded, nil
}
func signalingWireError(format string, values ...any) error {
return ringerrors.NewBadRequestError(fmt.Sprintf(format, values...), nil)
}
func wrapSignalingWireError(cause error, format string, values ...any) error {
return ringerrors.NewBadRequestError(fmt.Sprintf(format, values...), cause)
}
func bodyHasProperty(body json.RawMessage, key string) bool {
var fields map[string]json.RawMessage
return json.Unmarshal(body, &fields) == nil && fields != nil && fields[key] != nil
}
package websocket
import (
"bytes"
"encoding/json"
"strconv"
"github.com/portpowered/go-ring/internal/protocol"
)
func normalizeCloseReasonCode(encoded []byte) ([]byte, error) {
var frame map[string]json.RawMessage
err := json.Unmarshal(encoded, &frame)
if err != nil {
return nil, wrapSignalingWireError(err, "decode generated close frame")
}
body, err := rawObjectField(frame, protocol.FieldBody)
if err != nil {
return nil, err
}
reason, exists := body[protocol.FieldReason]
if !exists {
return encoded, nil
}
var reasonFields map[string]json.RawMessage
err = json.Unmarshal(reason, &reasonFields)
if err != nil || reasonFields == nil {
return nil, signalingWireError("close reason must be an object")
}
code, exists := reasonFields[protocol.FieldCode]
if !exists {
return encoded, nil
}
if !validCloseReasonCode(code) {
return nil, signalingWireError("close reason code must be an integer or string")
}
delete(reasonFields, protocol.FieldCode)
reason, err = json.Marshal(reasonFields)
if err != nil {
return nil, wrapSignalingWireError(err, "normalize generated close reason")
}
body[protocol.FieldReason] = reason
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, wrapSignalingWireError(err, "normalize generated close body")
}
frame[protocol.FieldBody] = bodyBytes
normalized, err := json.Marshal(frame)
if err != nil {
return nil, wrapSignalingWireError(err, "normalize generated close frame")
}
return normalized, nil
}
func rawObjectField(object map[string]json.RawMessage, name string) (map[string]json.RawMessage, error) {
encoded, exists := object[name]
if !exists {
return nil, signalingWireError("signaling frame is missing %q", name)
}
var fields map[string]json.RawMessage
err := json.Unmarshal(encoded, &fields)
if err != nil || fields == nil {
return nil, signalingWireError("signaling frame field %q must be an object", name)
}
return fields, nil
}
func validCloseReasonCode(encoded json.RawMessage) bool {
var text string
if json.Unmarshal(encoded, &text) == nil {
return true
}
var number json.Number
decoder := json.NewDecoder(bytes.NewReader(encoded))
decoder.UseNumber()
err := decoder.Decode(&number)
if err != nil || number.String() == "" {
return false
}
integer, err := strconv.ParseInt(number.String(), 10, 64)
return err == nil && strconv.FormatInt(integer, 10) == number.String()
}
package websocket
import (
"context"
"encoding/json"
"sync"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/signaling"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
)
type signalingWriteRequest struct {
ctx context.Context
message signaling.Message
result chan error
}
// SignalingWriter bounds outgoing frames and prioritizes close, heartbeat, and PTZ stop.
type SignalingWriter struct {
done <-chan struct{}
wake chan struct{}
finished chan struct{}
write func(context.Context, signaling.Message) error
onFailure func(error)
mu sync.Mutex
priority []*signalingWriteRequest
normal []*signalingWriteRequest
streak int
}
// NewSignalingWriter constructs a writer that stops when done closes.
func NewSignalingWriter(
done <-chan struct{},
write func(context.Context, signaling.Message) error,
onFailure func(error),
) *SignalingWriter {
return &SignalingWriter{
done: done,
wake: make(chan struct{}, 1),
finished: make(chan struct{}),
write: write,
onFailure: onFailure,
}
}
// Finished closes after Run drains queued requests.
func (w *SignalingWriter) Finished() <-chan struct{} { return w.finished }
// Send queues one frame and waits for the write or cancellation.
func (w *SignalingWriter) Send(ctx context.Context, message signaling.Message) error {
err := ctx.Err()
if err != nil {
return wrapSignalingContextError(err)
}
request := &signalingWriteRequest{ctx: ctx, message: message, result: make(chan error, 1)}
if !w.queue(request) {
return signaling.ErrBackpressure
}
select {
case err := <-request.result:
return err
case <-ctx.Done():
w.remove(request)
return wrapSignalingContextError(ctx.Err())
case <-w.done:
w.remove(request)
return signaling.ErrClosed
}
}
// Run services the writer queue until done closes.
func (w *SignalingWriter) Run() {
defer close(w.finished)
for {
select {
case <-w.done:
w.drain()
return
default:
}
request := w.next()
if request == nil {
select {
case <-w.wake:
continue
case <-w.done:
w.drain()
return
}
}
select {
case <-w.done:
err := request.ctx.Err()
if err == nil {
err = signaling.ErrClosed
} else {
err = wrapSignalingContextError(err)
}
request.result <- err
w.drain()
return
default:
}
err := request.ctx.Err()
if err == nil {
err = w.write(request.ctx, request.message)
} else {
err = wrapSignalingContextError(err)
}
request.result <- err
if err != nil && request.ctx.Err() == nil && w.onFailure != nil {
w.onFailure(err)
}
}
}
func (w *SignalingWriter) queue(request *signalingWriteRequest) bool {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.priority)+len(w.normal) >= signaling.SignalingWriteQueueCapacity {
return false
}
if prioritySignalingMessage(request.message) {
w.priority = append(w.priority, request)
} else {
w.normal = append(w.normal, request)
}
select {
case w.wake <- struct{}{}:
default:
}
return true
}
func (w *SignalingWriter) remove(request *signalingWriteRequest) {
w.mu.Lock()
defer w.mu.Unlock()
for _, queue := range []*[]*signalingWriteRequest{&w.priority, &w.normal} {
for i, queued := range *queue {
if queued == request {
*queue = append((*queue)[:i], (*queue)[i+1:]...)
return
}
}
}
}
func (w *SignalingWriter) next() *signalingWriteRequest {
w.mu.Lock()
defer w.mu.Unlock()
if len(w.priority) == 0 && len(w.normal) == 0 {
return nil
}
if len(w.normal) > 0 && (len(w.priority) == 0 || w.streak >= signaling.PriorityWriteBurstLimit) {
next := w.normal[0]
w.normal = w.normal[1:]
w.streak = 0
return next
}
next := w.priority[0]
w.priority = w.priority[1:]
w.streak++
return next
}
func (w *SignalingWriter) drain() {
w.mu.Lock()
queued := make([]*signalingWriteRequest, 0, len(w.priority)+len(w.normal))
queued = append(queued, w.priority...)
queued = append(queued, w.normal...)
w.priority, w.normal = nil, nil
w.mu.Unlock()
for _, request := range queued {
err := request.ctx.Err()
if err == nil {
err = signaling.ErrClosed
} else {
err = wrapSignalingContextError(err)
}
request.result <- err
}
}
func prioritySignalingMessage(message signaling.Message) bool {
if message.Method == protocol.MethodClose || message.Method == protocol.MethodPing ||
message.Method == protocol.MethodPong {
return true
}
if message.Method != protocol.MethodRPC {
return false
}
var body generatedsignaling.PtzContinuousCommandBody
if json.Unmarshal(message.Body, &body) != nil || body.Command == nil || body.Command.Params == nil ||
body.Command.Method == nil || body.Command.Params.Speed != 0 {
return false
}
method, ok := body.Command.Method.Value().(string)
if !ok || (method != protocol.RPCPanContinuous && method != protocol.RPCTiltContinuous) {
return false
}
return hasExplicitPTZStop(message.Body, method)
}
func hasExplicitPTZStop(encoded json.RawMessage, method string) bool {
var envelope map[string]json.RawMessage
if json.Unmarshal(encoded, &envelope) != nil {
return false
}
var command map[string]json.RawMessage
if json.Unmarshal(envelope[protocol.FieldCommand], &command) != nil {
return false
}
var wireMethod string
if json.Unmarshal(command[protocol.FieldMethod], &wireMethod) != nil || wireMethod != method {
return false
}
var params map[string]json.RawMessage
if json.Unmarshal(command[protocol.FieldParams], ¶ms) != nil {
return false
}
var speed float64
return json.Unmarshal(params[protocol.FieldSpeed], &speed) == nil && speed == 0
}
// Package ring provides the main client for interacting with Ring services.
// It orchestrates authentication, API clients (REST), and event connections.
package ring
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/requestauth"
"github.com/portpowered/go-ring/pkg/dependencies/rest"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// NewClient creates a new Ring client.
func NewClient(opts ...Option) (*Client, error) {
client := &Client{
restClient: rest.NewClient(),
userAgent: protocol.DefaultUserAgent,
region: RegionUS,
eventWebSocketURL: protocol.ExperimentalEventWebSocketURL,
}
err := client.applyEndpointConfiguration()
if err != nil {
return nil, err
}
for _, opt := range opts {
err := opt.apply(client)
if err != nil {
return nil, err
}
}
return client, nil
}
type accessTokenClaims struct {
HardwareID string `json:"hardware_id"`
}
func hardwareIDFromAccessToken(accessToken string) string {
parts := strings.Split(accessToken, ".")
if len(parts) != protocol.JWTCompactSegmentCount {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
var claims accessTokenClaims
if json.Unmarshal(payload, &claims) != nil {
return ""
}
return claims.HardwareID
}
func (c *Client) ensureSession(ctx context.Context) error {
auth, ok := requestauth.FromContext(ctx)
if !ok || auth.HardwareID == "" {
return nil
}
return c.restClient.RegisterSession(ctx)
}
// getToken retrieves the access token.
func (c *Client) getToken(ctx context.Context) (string, error) {
if auth, ok := requestauth.FromContext(ctx); ok {
if auth.AccessToken == "" {
return "", ringapimodels.NewTokenError("no token in request", nil)
}
return auth.AccessToken, nil
}
return "", ringapimodels.NewTokenError("no token in request", nil)
}
func (c *Client) accountContext(ctx context.Context, auth AuthContext) context.Context {
hardwareID := auth.HardwareID
if hardwareID == "" {
hardwareID = hardwareIDFromAccessToken(auth.AccessToken)
}
return requestauth.WithAccount(ctx, requestauth.Account{
AccessToken: auth.AccessToken,
HardwareID: hardwareID,
})
}
func (c *Client) hardwareIDFor(ctx context.Context) string {
if auth, ok := requestauth.FromContext(ctx); ok {
return auth.HardwareID
}
return ""
}
// Close is retained for source compatibility. Connection lifetimes belong to
// the objects returned by connection-opening methods; close those objects to
// end their connections. Client currently owns no long-lived connections.
func (c *Client) Close() error {
return nil
}
package ring
import (
"context"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// Authenticate performs an isolated exchange and never binds its token to Client.
// Use NewLoginSession when a 2FA challenge must be completed in the same flow.
func (c *Client) Authenticate(ctx context.Context, req AuthenticateRequest) (*ringapimodels.AuthResponse, error) {
session, err := c.NewLoginSession(
LoginSessionRequest{Username: req.Username, Password: req.Password, HardwareID: req.HardwareID},
)
if err != nil {
return nil, err
}
defer func() { _ = session.Close() }()
return session.Authenticate(ctx, CompleteLoginRequest{OTPCode: req.OTPCode})
}
// Request2FACode starts an isolated exchange. NewLoginSession retains its
// challenge when the caller needs to follow it with Authenticate.
func (c *Client) Request2FACode(ctx context.Context, req Request2FACodeRequest) error {
session, err := c.NewLoginSession(LoginSessionRequest(req))
if err != nil {
return err
}
defer func() { _ = session.Close() }()
return session.Request2FACode(ctx)
}
// RefreshToken refreshes an access token using a refresh token.
func (c *Client) RefreshToken(ctx context.Context, req RefreshTokenRequest) (*ringapimodels.AuthResponse, error) {
if req.RefreshToken == "" {
return nil, ringapimodels.NewBadRequestError("refresh token is required", nil)
}
tokenResp, err := c.restClient.RefreshAccessTokenFor(ctx, req.RefreshToken, req.HardwareID)
if err != nil {
return nil, err
}
return &ringapimodels.AuthResponse{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenString(tokenResp.RefreshToken),
ExpiresIn: tokenInt(tokenResp.ExpiresIn),
TokenType: tokenResp.TokenType,
}, nil
}
func tokenString(value *string) string {
if value != nil {
return *value
}
return ""
}
func tokenInt(value *int) int {
if value != nil {
return *value
}
return 0
}
package ring
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
func (c *Client) GetDeviceDetail(ctx context.Context, req GetDeviceDetailRequest) (*DeviceDetail, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return nil, err
}
{
err = c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.GetDeviceDetail(ctx, id)
if err != nil {
return nil, err
}
detail, err := projectCaptured[DeviceDetail](wire)
if err != nil {
return nil, err
}
detail.OperationSets = operationNames(wire.DeviceOperationSet)
return detail, nil
}
// GetDeviceStatus returns the connection and battery state derived from device detail.
func (c *Client) GetDeviceStatus(ctx context.Context, req GetDeviceDetailRequest) (*DeviceStatus, error) {
detail, err := c.GetDeviceDetail(ctx, req)
if err != nil {
return nil, err
}
status := detail.Device.Status()
return &status, nil
}
func (c *Client) ListLocations(ctx context.Context, req ListLocationsRequest) (*LocationList, error) {
ctx = c.accountContext(ctx, req.Auth)
{
err := c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.ListLocations(ctx)
if err != nil {
return nil, err
}
locations, err := projectCaptured[LocationList](wire)
if err != nil {
return nil, err
}
locations.OperationSets = operationNames(wire.LocationOperationSets)
return locations, nil
}
func operationNames(sets *map[string]generatedhttp.DeviceOperationSet) map[string][]string {
if sets == nil {
return nil
}
names := make(map[string][]string, len(*sets))
for name, operations := range *sets {
for operation := range operations {
names[name] = append(names[name], operation)
}
sort.Strings(names[name])
}
return names
}
func locationID(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", ringapimodels.NewBadRequestError("location ID is required", nil)
}
return value, nil
}
func (c *Client) GetLocation(ctx context.Context, req GetLocationRequest) (*LocationDetail, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := locationID(req.LocationID)
if err != nil {
return nil, err
}
{
err = c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.GetLocation(ctx, id, req.Params.wire())
if err != nil {
return nil, err
}
return projectCaptured[LocationDetail](wire)
}
func (c *Client) ListLocationGroups(ctx context.Context, req LocationRequest) (*LocationGroups, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := locationID(req.LocationID)
if err != nil {
return nil, err
}
{
err = c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.ListLocationGroups(ctx, id)
if err != nil {
return nil, err
}
return projectCaptured[LocationGroups](wire)
}
func (c *Client) ListLocationDevices(ctx context.Context, req LocationRequest) (*LocationGroupDevices, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := locationID(req.LocationID)
if err != nil {
return nil, err
}
{
err = c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.ListLocationDevices(ctx, id)
if err != nil {
return nil, err
}
return projectCaptured[LocationGroupDevices](wire)
}
func (c *Client) GetDeviceTimeline(ctx context.Context, req GetDeviceTimelineRequest) (*DeviceTimeline, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return nil, err
}
if req.Params.Limit != nil && *req.Params.Limit <= 0 {
return nil, ringapimodels.NewBadRequestError("timeline limit must be positive", nil)
}
{
err = c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.GetDeviceTimeline(ctx, id, req.Params.wire())
if err != nil {
return nil, err
}
return projectCaptured[DeviceTimeline](wire)
}
func (c *Client) GetHistoryDevices(ctx context.Context, req GetHistoryDevicesRequest) (*HistoryDevices, error) {
ctx = c.accountContext(ctx, req.Auth)
{
err := c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
wire, err := c.restClient.GetHistoryDevices(ctx, req.Params.wire())
if err != nil {
return nil, err
}
return projectCaptured[HistoryDevices](wire)
}
func (c *Client) RebootDevice(ctx context.Context, req DeviceIDRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.RebootDevice(ctx, id)
}
// UnlockIntercom requests an unlock on an intercom device ID.
func (c *Client) UnlockIntercom(ctx context.Context, req DeviceIDRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.UnlockIntercom(ctx, id)
}
func (c *Client) SetPersistentLiveViewEnabled(ctx context.Context, req SetPersistentLiveViewEnabledRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.SetPersistentLiveViewEnabled(ctx, id, req.Enabled)
}
func recordingID(value int64) (int64, error) {
if value <= 0 {
return 0, ringapimodels.NewBadRequestError("recording ID must be positive", nil)
}
return value, nil
}
func (c *Client) FavoriteRecording(ctx context.Context, req RecordingIDRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := recordingID(req.RecordingID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.FavoriteRecording(ctx, id)
}
func (c *Client) DeleteRecording(ctx context.Context, req DeleteRecordingRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := recordingID(req.RecordingID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.DeleteRecording(ctx, id, req.ConfirmDeleteFavorite)
}
// GetCapturedTickets reads the recorded location ticket resource. It does not
// replace the separate POST ticket used by OpenSignaling.
func (c *Client) GetCapturedTickets(ctx context.Context, req GetCapturedTicketsRequest) (*CapturedTickets, error) {
ctx = c.accountContext(ctx, req.Auth)
if c.endpoints.SolutionsBaseURL == "" {
return nil, ringapimodels.NewConnectionError("Solutions endpoint is not configured for this region", nil)
}
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
wire, err := generatedhttp.NewClientWithResponses(
c.endpoints.SolutionsBaseURL,
generatedhttp.WithHTTPClient(c.restClient.HTTPClient()),
)
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid Solutions endpoint", err)
}
params := req.Params.wire()
response, err := wire.GetCapturedLocationTicketsWithResponse(
ctx,
¶ms,
func(_ context.Context, request *http.Request) error {
request.Header.Set(string(generatedhttp.Authorization), "Bearer "+token)
request.Header.Set(string(generatedhttp.Accept), "application/json")
request.Header.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
request.Header.Set(string(generatedhttp.HardwareId), hardwareID)
}
return nil
},
)
if err != nil {
var (
syntax *json.SyntaxError
unmarshal *json.UnmarshalTypeError
)
if errors.As(err, &syntax) || errors.As(err, &unmarshal) {
return nil, ringapimodels.NewInternalServerError("captured ticket response is malformed", err)
}
return nil, ringapimodels.NewNetworkError("captured ticket request failed", err)
}
if response.JSON200 != nil {
if response.JSON200.Ticket == "" {
return nil, ringapimodels.NewInternalServerError("captured ticket response lacks a ticket", nil)
}
return projectCaptured[CapturedTickets](response.JSON200)
}
if response.StatusCode() < 200 || response.StatusCode() >= 300 {
return nil, ringapimodels.ClassifyHTTPError(response.HTTPResponse, string(response.Body))
}
return nil, ringapimodels.NewInternalServerError("captured ticket response was not JSON", nil)
}
package ring
import (
"context"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// SetVolume uses the legacy chime or doorbell route selected by Kind.
func (c *Client) SetVolume(ctx context.Context, req SetVolumeRequest) error {
ctx = c.accountContext(ctx, req.Auth)
if req.Volume < 0 || req.Volume > 11 {
return ringapimodels.NewBadRequestError("volume must be between 0 and 11", nil)
}
if !req.Kind.Valid() {
return ringapimodels.NewBadRequestError("volume kind must be chime or doorbell", nil)
}
if req.Description == "" {
return ringapimodels.NewBadRequestError("device description is required for legacy volume update", nil)
}
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
if req.Kind == ringapimodels.VolumeKindChime {
return c.restClient.SetChimeVolume(ctx, deviceIDInt, req.Description, req.Volume)
}
return c.restClient.SetDoorbellVolume(ctx, deviceIDInt, req.Description, req.Volume)
}
// SetLights sets the lights for a device (floodlight cams).
func (c *Client) SetLights(ctx context.Context, req SetLightsRequest) error {
ctx = c.accountContext(ctx, req.Auth)
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
return c.restClient.SetLights(ctx, deviceIDInt, req.Enabled)
}
// SetMotionDetection sets motion detection for a device.
func (c *Client) SetMotionDetection(ctx context.Context, req SetMotionDetectionRequest) error {
ctx = c.accountContext(ctx, req.Auth)
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
return c.restClient.SetMotionDetection(ctx, deviceIDInt, req.Enabled)
}
// TestSound tests a sound on a chime device
// req.Sound can be "ding" or "motion".
func (c *Client) TestSound(ctx context.Context, req TestSoundRequest) error {
ctx = c.accountContext(ctx, req.Auth)
wireKind := generatedhttp.TestChimeSoundParamsKind(req.Sound)
if !req.Sound.Valid() || !wireKind.Valid() {
return ringapimodels.NewBadRequestError("sound must be 'ding' or 'motion'", nil)
}
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
return c.restClient.TestSound(ctx, deviceIDInt, wireKind)
}
// SetInHomeChime sets in-home chime settings for a doorbell
// req.Settings can include: "type" (e.g., "Mechanical"), "enabled" (bool), "duration" (int).
func (c *Client) SetInHomeChime(ctx context.Context, req SetInHomeChimeRequest) error {
ctx = c.accountContext(ctx, req.Auth)
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
settings := req.Settings
count := 0
if settings.Type != nil {
count++
}
if settings.Enabled != nil {
count++
}
if settings.Duration != nil {
count++
}
if count != 1 {
return ringapimodels.NewBadRequestError("exactly one in-home chime setting is required", nil)
}
if req.Description == "" {
return ringapimodels.NewBadRequestError("device description is required for legacy in-home chime update", nil)
}
if (settings.Type != nil && *settings.Type < 0) || (settings.Duration != nil && *settings.Duration < 0) {
return ringapimodels.NewBadRequestError("negative in-home chime setting", nil)
}
if settings.Type != nil {
return c.restClient.SetInHomeChimeType(ctx, deviceIDInt, req.Description, *settings.Type)
}
if settings.Duration != nil {
return c.restClient.SetInHomeChimeDuration(ctx, deviceIDInt, req.Description, *settings.Duration)
}
return c.restClient.SetInHomeChimeEnabled(ctx, deviceIDInt, req.Description, *settings.Enabled)
}
package ring
import (
"context"
"strconv"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// ListDevices retrieves all devices associated with the account.
func (c *Client) ListDevices(ctx context.Context, req ListDevicesRequest) (*ringapimodels.DevicesResponse, error) {
ctx = c.accountContext(ctx, req.Auth)
{
err := c.ensureSession(ctx)
if err != nil {
return nil, err
}
}
rawResponse, err := c.restClient.GetDevices(ctx)
if err != nil {
return nil, err
}
response := &ringapimodels.DevicesResponse{Devices: make([]ringapimodels.Device, 0, len(rawResponse.Devices))}
for _, raw := range rawResponse.Devices {
response.Devices = append(response.Devices, convertDevice(raw))
}
return response, nil
}
// GetDevice retrieves a specific device by ID.
func (c *Client) GetDevice(ctx context.Context, req GetDeviceRequest) (*ringapimodels.Device, error) {
ctx = c.accountContext(ctx, req.Auth)
devices, err := c.ListDevices(ctx, ListDevicesRequest{Auth: req.Auth})
if err != nil {
return nil, err
}
for i := range devices.Devices {
if devices.Devices[i].ID == req.DeviceID {
return &devices.Devices[i], nil
}
}
return nil, ringapimodels.NewNotFoundError("device not found", nil)
}
// UpdateDeviceHealth refreshes health data via the generic device route.
func (c *Client) UpdateDeviceHealth(
ctx context.Context,
req UpdateDeviceHealthRequest,
) (*ringapimodels.DeviceHealth, error) {
ctx = c.accountContext(ctx, req.Auth)
deviceIDInt, err := settingsDeviceID(req.DeviceID)
if err != nil {
return nil, err
}
health, err := c.restClient.GetDeviceHealth(ctx, deviceIDInt)
if err != nil {
return nil, err
}
return convertToDeviceHealth(health), nil
}
// getDeviceName returns the device name, using Description if Name is empty.
func getDeviceName(raw generatedhttp.Device) string {
if raw.Name != nil && *raw.Name != "" {
return *raw.Name
}
return raw.Description
}
// getDeviceTimezone returns the device timezone, using TimeZone if Timezone is empty.
func getDeviceTimezone(raw generatedhttp.Device) string {
if raw.Timezone != nil && *raw.Timezone != "" {
return *raw.Timezone
}
return wireString(raw.TimeZone)
}
func wireString(value *string) string {
if value != nil {
return *value
}
return ""
}
func convertDevice(raw generatedhttp.Device) ringapimodels.Device {
device := ringapimodels.Device{
ID: strconv.FormatInt(raw.Id, 10),
Name: getDeviceName(raw),
Kind: raw.Kind,
Type: deviceType(raw),
Family: wireString(raw.Family),
Address: wireString(raw.Address),
Timezone: getDeviceTimezone(raw),
WifiName: raw.WifiName,
WifiSignalStrength: raw.WifiSignalStrength,
Volume: raw.Volume,
LightBrightness: raw.LightBrightness,
MotionDetectionEnabled: raw.MotionDetectionEnabled,
Capabilities: deviceCapabilities(raw),
Health: nil,
}
if raw.Health != nil {
device.Health = convertInventoryHealth(raw.Health)
}
return device
}
// deviceType uses the schema's known hardware catalogs. Capabilities describe
// supported operations independently and cannot establish a hardware class.
func deviceType(raw generatedhttp.Device) ringapimodels.DeviceType {
switch wireString(raw.Family) {
case string(generatedhttp.Doorbots):
return ringapimodels.DeviceTypeDoorbell
case string(generatedhttp.Chimes):
return ringapimodels.DeviceTypeChime
case string(generatedhttp.StickupCams):
return ringapimodels.DeviceTypeCamera
case "", string(generatedhttp.Other):
// Missing and generic families may still have a known hardware kind.
default:
return ringapimodels.DeviceTypeOther
}
switch {
case generatedhttp.CameraDeviceKind(raw.Kind).Valid():
return ringapimodels.DeviceTypeCamera
case generatedhttp.DoorbellDeviceKind(raw.Kind).Valid():
return ringapimodels.DeviceTypeDoorbell
case generatedhttp.ChimeDeviceKind(raw.Kind).Valid():
return ringapimodels.DeviceTypeChime
default:
return ringapimodels.DeviceTypeOther
}
}
func deviceCapabilities(raw generatedhttp.Device) []ringapimodels.DeviceCapability {
capabilities := make([]ringapimodels.DeviceCapability, 0)
if raw.HasLight != nil && *raw.HasLight {
capabilities = append(capabilities, ringapimodels.DeviceCapabilityLight)
}
if raw.MotionDetectionEnabled != nil {
capabilities = append(capabilities, ringapimodels.DeviceCapabilityMotionDetection)
}
if raw.Health == nil {
return capabilities
}
if raw.Health.SirenOn != nil {
capabilities = append(capabilities, ringapimodels.DeviceCapabilitySiren)
}
if raw.Health.VodEnabled != nil && *raw.Health.VodEnabled {
capabilities = append(capabilities, ringapimodels.DeviceCapabilityLiveView)
}
if raw.Health.SupportedRpcCommands == nil {
return capabilities
}
for _, command := range *raw.Health.SupportedRpcCommands {
switch command {
case protocol.RPCPanStep:
capabilities = append(capabilities, ringapimodels.DeviceCapabilityPtzPanStep)
case protocol.RPCTiltStep:
capabilities = append(capabilities, ringapimodels.DeviceCapabilityPtzTiltStep)
case protocol.RPCPanContinuous:
capabilities = append(capabilities, ringapimodels.DeviceCapabilityPtzPanContinuous)
case protocol.RPCTiltContinuous:
capabilities = append(capabilities, ringapimodels.DeviceCapabilityPtzTiltContinuous)
}
}
return capabilities
}
// convertInventoryHealth maps typed inventory health into the public projection.
func convertInventoryHealth(raw *generatedhttp.DeviceHealth) *ringapimodels.DeviceHealth {
return &ringapimodels.DeviceHealth{
BatteryLevel: raw.BatteryLevel,
BatteryStatus: raw.BatteryStatus,
SignalStrength: raw.SignalStrength,
FirmwareVersion: raw.FirmwareVersion,
LastUpdate: raw.LastUpdate,
}
}
// convertToDeviceHealth converts the separate legacy health endpoint's JSON.
func convertToDeviceHealth(raw *generatedhttp.LegacyDeviceHealth) *ringapimodels.DeviceHealth {
return &ringapimodels.DeviceHealth{
BatteryLevel: raw.BatteryLevel,
BatteryStatus: raw.BatteryStatus,
SignalStrength: raw.SignalStrength,
FirmwareVersion: raw.FirmwareVersion,
LastUpdate: raw.LastUpdate,
}
}
package ring
import (
"context"
"encoding/json"
"github.com/portpowered/go-ring/internal/protocol"
dependencywebsocket "github.com/portpowered/go-ring/pkg/dependencies/websocket"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// ConnectEvents establishes a WebSocket connection for receiving events
// This is a wrapper that calls the internal ConnectEvents method.
func (c *Client) ConnectEvents(ctx context.Context, req ConnectEventsRequest) (*EventConnection, error) {
ctx = c.accountContext(ctx, req.Auth)
token, err := c.getToken(ctx)
if err != nil {
return nil, ringapimodels.NewTokenError("failed to get token for event connection", err)
}
if c.eventWebSocketURL == protocol.ExperimentalEventWebSocketURL {
err = protocol.ValidateWebSocketURL(protocol.AccountEventsChannel, c.eventWebSocketURL)
} else {
err = protocol.ValidateWebSocketOverride(c.eventWebSocketURL)
}
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid account event WebSocket URL", err)
}
conn, err := dependencywebsocket.OpenEventsWithDialer(
ctx,
c.eventWebSocketURL,
token,
c.hardwareIDFor(ctx),
c.websocketDialer,
)
if err != nil {
return nil, ringapimodels.NewConnectionError("failed to connect to event stream", err)
}
return &EventConnection{transport: conn}, nil
}
func (c *EventConnection) Receive() (*ringapimodels.Event, error) {
frame, raw, err := c.transport.ReceiveFrame()
if err != nil {
if ringapimodels.IsClosedError(err) {
return nil, ringapimodels.NewClosedError("event connection closed", err)
}
return nil, ringapimodels.NewConnectionError("failed to receive event frame", err)
}
eventValue := ringapimodels.Event{
Data: nil,
DeviceID: int64(frame.DeviceId),
Kind: frame.Kind,
Timestamp: frame.Timestamp,
}
err = json.Unmarshal(raw, &eventValue.Data)
if err != nil {
return nil, ringapimodels.NewConnectionError("invalid account event payload", err)
}
if eventValue.Data == nil {
return nil, ringapimodels.NewConnectionError("invalid account event payload", nil)
}
return &eventValue, nil
}
func (c *EventConnection) Close() error {
err := c.transport.Close()
if err != nil {
return ringapimodels.NewNetworkError("close event connection", err)
}
return nil
}
// Listen listens for events, calls the callback for each event, and returns a context error when canceled.
func (c *Client) Listen(ctx context.Context, callback ringapimodels.EventCallback, req ConnectEventsRequest) error {
conn, err := c.ConnectEvents(ctx, req)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
for {
select {
case <-ctx.Done():
return ringapimodels.NewConnectionError("event listener canceled", ctx.Err())
default:
event, err := conn.Receive()
if err != nil {
ctxErr := ctx.Err()
if ctxErr != nil {
return ringapimodels.NewConnectionError("event listener canceled", ctxErr)
}
if ringapimodels.IsClosedError(err) {
return nil
}
return err
}
if callback != nil {
err := callback(event)
if err != nil {
return err
}
}
}
}
}
package ring
import (
"fmt"
"net/http"
"github.com/portpowered/go-ring/pkg/dependencies/rest"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
func WithRegion(region Region) Option { return withRegion(region) }
type withRegion Region
func (w withRegion) apply(client *Client) error {
region := Region(w)
if region != RegionUS && region != RegionEU && region != RegionFE {
return ringapimodels.NewBadRequestError(fmt.Sprintf("unsupported region %q", region), nil)
}
client.region = region
return client.applyEndpointConfiguration()
}
// WithEndpoints overrides endpoint origins for this client. Overrides take
// precedence over the selected region regardless of option order.
func WithEndpoints(endpoints Endpoints) Option { return withEndpoints(endpoints) }
type withEndpoints Endpoints
func (w withEndpoints) apply(client *Client) error {
err := validateEndpoints(Endpoints(w))
if err != nil {
return err
}
client.endpointOverrides = Endpoints(w)
return client.applyEndpointConfiguration()
}
// WithHTTPClient sets a custom HTTP client for REST API calls.
func WithHTTPClient(httpClient *http.Client) Option {
return withHTTPClient{httpClient: httpClient}
}
type withHTTPClient struct {
httpClient *http.Client
}
func (w withHTTPClient) apply(client *Client) error {
if w.httpClient == nil {
return ringapimodels.NewBadRequestError("HTTP client must not be nil", nil)
}
if w.httpClient.Jar != nil {
return ringapimodels.NewBadRequestError("shared HTTP client must not have a cookie jar", nil)
}
// Keep the caller's transport, but snapshot the mutable client fields so a
// Jar assigned after construction cannot become shared account state.
httpClient := *w.httpClient
client.restClient.Apply(rest.WithHTTPClient(&httpClient))
return nil
}
// WithUserAgent sets a custom user agent.
func WithUserAgent(userAgent string) Option {
return withUserAgent(userAgent)
}
type withUserAgent string
func (w withUserAgent) apply(c *Client) error {
ua := string(w)
c.userAgent = ua
c.restClient.Apply(
rest.WithUserAgent(ua),
)
return nil
}
// WithWebSocketDialer configures the event and signaling WebSocket transport.
func WithWebSocketDialer(d WebSocketDialer) Option { return withWebSocketDialer{dialer: d} }
type withWebSocketDialer struct{ dialer WebSocketDialer }
func (o withWebSocketDialer) apply(c *Client) error {
if o.dialer == nil {
return ringapimodels.NewBadRequestError("WebSocket dialer must not be nil", nil)
}
c.websocketDialer = o.dialer
return nil
}
// WithSignalingWebSocketURL explicitly overrides the configured signaling URL. It
// takes precedence over WithEndpoints and the selected region, regardless of
// option order.
func WithSignalingWebSocketURL(url string) Option {
return withSignalingWebSocketURL(url)
}
type withSignalingWebSocketURL string
func (w withSignalingWebSocketURL) apply(client *Client) error {
err := validateEndpoints(Endpoints{
OAuthBaseURL: "",
APIBaseURL: "",
SolutionsBaseURL: "",
SignalingURL: string(w),
})
if err != nil {
return err
}
client.signalingWebSocketURL = string(w)
client.signalingWebSocketOverride = string(w)
return nil
}
// WithEventWebSocketURL configures the event endpoint, including local test servers.
// The default vendor endpoint is experimental; callers must verify vendor support.
func WithEventWebSocketURL(url string) Option {
return withEventWebSocketURL(url)
}
type withEventWebSocketURL string
var _ Option = withEventWebSocketURL("")
func (w withEventWebSocketURL) apply(client *Client) error {
err := validateEventWebSocketURL(string(w))
if err != nil {
return err
}
client.eventWebSocketURL = string(w)
return nil
}
package ring
import (
"context"
"encoding/json"
"net"
"net/http"
"strconv"
"sync"
"github.com/portpowered/go-ring/internal/generatedfcm"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/pkg/dependencies/push"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
const fcmEventBufferSize = 16
// ConnectPushRequest creates a caller-scoped FCM connection. Credentials come
// from a previous credentials event and are never stored on Client.
type ConnectPushRequest struct {
Auth AuthContext
Credentials json.RawMessage
DeviceIDs []string
Ding bool
Motion bool
}
type RegisterPushDeviceRequest struct {
Auth AuthContext
Token string
}
func (c *Client) RegisterPushDevice(ctx context.Context, req RegisterPushDeviceRequest) error {
if req.Token == "" {
return ringapimodels.NewBadRequestError("FCM token is required", nil)
}
ctx = c.accountContext(ctx, req.Auth)
err := c.ensureSession(ctx)
if err != nil {
return err
}
return c.restClient.RegisterPushDevice(ctx, req.Token)
}
func (c *Client) SubscribeDeviceDing(ctx context.Context, req DeviceIDRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.SubscribeDeviceDing(ctx, id)
}
func (c *Client) SubscribeDeviceMotion(ctx context.Context, req DeviceIDRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
{
err = c.ensureSession(ctx)
if err != nil {
return err
}
}
return c.restClient.SubscribeDeviceMotion(ctx, id)
}
type FCMEventKind string
const (
PushCredentials FCMEventKind = "credentials"
PushRegistered FCMEventKind = "registered"
PushConnected FCMEventKind = "connected"
PushMessage FCMEventKind = "message"
PushRetry FCMEventKind = "retry"
PushClosed FCMEventKind = "closed"
)
type PushAction string
const (
PushActionDing PushAction = "ding"
PushActionMotion PushAction = "motion"
)
// PushEvent reports setup progress and incoming notifications. Credentials are
// sensitive and should be saved securely by the caller for reconnection.
type FCMEvent struct {
Kind FCMEventKind
Token string
DeviceID string
Action PushAction
Credentials json.RawMessage
Data json.RawMessage
Err error
}
// FCMSource supplies receiver events. Most callers use the built-in receiver;
// an alternate source can be injected for controlled replay or other transports.
type FCMSource func(context.Context, json.RawMessage) (<-chan FCMEvent, error)
// FCMDialContext supplies the already-secured connection used for FCM MCS
// frames. The default receiver performs TLS before returning its connection.
type FCMDialContext func(context.Context, string, string) (net.Conn, error)
// WithFCMSource replaces the receiver while preserving Ring registration and
// event lifecycle behavior.
func WithFCMSource(source FCMSource) Option { return withFCMSource{source: source} }
type withFCMSource struct{ source FCMSource }
func (option withFCMSource) apply(c *Client) error {
if option.source == nil {
return ringapimodels.NewBadRequestError("FCM source must not be nil", nil)
}
c.fcmSource = option.source
return nil
}
// WithFCMHTTPTransport configures the HTTP transport used by the built-in FCM
// receiver. It has no effect when WithFCMSource supplies a custom receiver.
func WithFCMHTTPTransport(transport http.RoundTripper) Option {
return withFCMHTTPTransport{transport: transport}
}
type withFCMHTTPTransport struct{ transport http.RoundTripper }
func (option withFCMHTTPTransport) apply(c *Client) error {
if option.transport == nil {
return ringapimodels.NewBadRequestError("FCM HTTP transport must not be nil", nil)
}
c.fcmHTTPTransport = option.transport
return nil
}
// WithFCMDialContext configures the context-aware FCM MCS connection function.
// The default receiver establishes TLS; injected functions return the
// connection that carries MCS frames.
func WithFCMDialContext(dialContext FCMDialContext) Option {
return withFCMDialContext{dialContext: dialContext}
}
type withFCMDialContext struct{ dialContext FCMDialContext }
func (option withFCMDialContext) apply(client *Client) error {
if option.dialContext == nil {
return ringapimodels.NewBadRequestError("FCM dial context must not be nil", nil)
}
client.fcmDialContext = option.dialContext
return nil
}
// PushConnection owns one FCM receiver and Ring device subscriptions.
type PushConnection struct {
client *Client
ctx context.Context
cancel context.CancelFunc
events chan FCMEvent
done chan struct{}
closeMu sync.Once
}
func (c *Client) ConnectPush(ctx context.Context, req ConnectPushRequest) (*PushConnection, error) {
if req.Auth.AccessToken == "" {
return nil, ringapimodels.NewTokenError("push connection requires an access token", nil)
}
deviceIDs := make([]int64, 0, len(req.DeviceIDs))
for _, rawID := range req.DeviceIDs {
id, err := settingsDeviceID(rawID)
if err != nil {
return nil, err
}
deviceIDs = append(deviceIDs, id)
}
connectionCtx, cancel := context.WithCancel(c.accountContext(ctx, req.Auth))
source := c.fcmSource
if source == nil {
source = func(ctx context.Context, saved json.RawMessage) (<-chan FCMEvent, error) {
return defaultFCMSource(ctx, saved, c.fcmHTTPTransport, c.fcmDialContext)
}
}
stream, err := source(connectionCtx, req.Credentials)
if err != nil {
cancel()
return nil, ringapimodels.NewBadRequestError("invalid saved push credentials", err)
}
connection := &PushConnection{
client: c,
ctx: connectionCtx,
cancel: cancel,
events: make(chan FCMEvent, fcmEventBufferSize),
done: make(chan struct{}),
}
go connection.run(stream, deviceIDs, req.Ding, req.Motion)
return connection, nil
}
// Events closes when the connection ends. Receive errors arrive as retry events.
func (p *PushConnection) Events() <-chan FCMEvent { return p.events }
func (p *PushConnection) Close() error {
p.closeMu.Do(p.cancel)
<-p.done
return nil
}
func (p *PushConnection) run(stream <-chan FCMEvent, deviceIDs []int64, ding, motion bool) {
defer close(p.done)
defer close(p.events)
for {
var event FCMEvent
select {
case <-p.ctx.Done():
return
case received, ok := <-stream:
if !ok {
return
}
event = received
}
switch event.Kind {
case PushRegistered:
continue
case PushCredentials:
p.emit(FCMEvent{
Kind: PushCredentials,
Token: "",
DeviceID: "",
Action: "",
Credentials: event.Credentials,
Data: nil,
Err: nil,
})
err := p.register(event.Token, deviceIDs, ding, motion)
if err != nil {
p.emit(FCMEvent{
Kind: PushRetry,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: nil,
Err: err,
})
} else {
p.emit(FCMEvent{
Kind: PushRegistered,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: nil,
Err: nil,
})
}
case PushConnected:
p.emit(FCMEvent{
Kind: PushConnected,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: nil,
Err: nil,
})
case PushMessage:
p.emit(ParseFCMNotification(event.Data))
case PushRetry:
p.emit(FCMEvent{
Kind: PushRetry,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: nil,
Err: event.Err,
})
case PushClosed:
p.emit(FCMEvent{
Kind: PushClosed,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: nil,
Err: nil,
})
}
}
}
func defaultFCMSource(
ctx context.Context,
saved json.RawMessage,
transport http.RoundTripper,
dialContext FCMDialContext,
) (<-chan FCMEvent, error) {
stream, err := push.StartWithTransports(ctx, saved, transport, push.DialContextFunc(dialContext))
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid saved FCM credentials", err)
}
out := make(chan FCMEvent, fcmEventBufferSize)
go func() {
defer close(out)
for event := range stream {
mapped := FCMEvent{
Kind: FCMEventKind(event.Kind),
Token: event.Token,
DeviceID: "",
Action: "",
Credentials: event.Credentials,
Data: event.Data,
Err: event.Err,
}
select {
case out <- mapped:
case <-ctx.Done():
return
}
}
}()
return out, nil
}
func (p *PushConnection) register(token string, deviceIDs []int64, ding, motion bool) error {
err := p.client.ensureSession(p.ctx)
if err != nil {
return err
}
err = p.client.restClient.RegisterPushDevice(p.ctx, token)
if err != nil {
return err
}
for _, id := range deviceIDs {
if ding {
err := p.client.restClient.SubscribeDeviceDing(p.ctx, id)
if err != nil {
return err
}
}
if motion {
err := p.client.restClient.SubscribeDeviceMotion(p.ctx, id)
if err != nil {
return err
}
}
}
return nil
}
func (p *PushConnection) emit(event FCMEvent) {
select {
case p.events <- event:
case <-p.ctx.Done():
}
}
// ParseFCMNotification extracts common Ring event fields while retaining the
// original payload for newer event categories.
func ParseFCMNotification(data json.RawMessage) FCMEvent {
event := FCMEvent{
Kind: PushMessage,
Token: "",
DeviceID: "",
Action: "",
Credentials: nil,
Data: data,
Err: nil,
}
var envelope map[string]json.RawMessage
if json.Unmarshal(data, &envelope) != nil {
return event
}
nested := expandedObject(envelope[protocol.FCMPushDataKey])
if nested[protocol.FCMPushAndroidConfigKey] != nil || nested[protocol.FCMPushDataKey] != nil {
envelope = nested
}
for key, value := range envelope {
var encoded string
if json.Unmarshal(value, &encoded) == nil && json.Valid([]byte(encoded)) {
envelope[key] = json.RawMessage(encoded)
}
}
var config generatedfcm.RingPushNotificationConfig
_ = json.Unmarshal(envelope[protocol.FCMPushAndroidConfigKey], &config)
if config.Category != nil {
event.Action = PushAction(*config.Category)
}
var payload generatedfcm.RingPushNotificationPayload
_ = json.Unmarshal(envelope[protocol.FCMPushDataKey], &payload)
if payload.Device != nil && payload.Device.Id != nil {
event.DeviceID = strconv.FormatInt(*payload.Device.Id, 10)
}
if event.Action == "" && payload.GcmData != nil && payload.GcmData.Action != nil {
event.Action = PushAction(*payload.GcmData.Action)
}
if event.DeviceID == "" {
var id int64
if json.Unmarshal(envelope[protocol.FCMPushDoorbotIDKey], &id) == nil {
event.DeviceID = strconv.FormatInt(id, 10)
}
}
return event
}
func expandedObject(raw json.RawMessage) map[string]json.RawMessage {
var encoded string
if json.Unmarshal(raw, &encoded) == nil && json.Valid([]byte(encoded)) {
raw = json.RawMessage(encoded)
}
var object map[string]json.RawMessage
_ = json.Unmarshal(raw, &object)
return object
}
package ring
import (
"strings"
"time"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/pkg/ringtypes"
)
// DeviceFamily selects a legacy family endpoint. Only doorbots and chimes
// currently have a captured family-specific health operation.
type DeviceFamily = ringtypes.DeviceFamily
const (
DeviceFamilyDoorbells DeviceFamily = "doorbots"
DeviceFamilyChimes DeviceFamily = "chimes"
DeviceFamilyCameras DeviceFamily = "stickup_cams"
DeviceFamilyOther DeviceFamily = "other"
)
// HistoryKind is an open recording kind. These values are known from legacy
// history; unfamiliar server kinds may still be passed by conversion.
type HistoryKind string
const (
HistoryDing HistoryKind = "ding"
HistoryMotion HistoryKind = "motion"
HistoryOnDemand HistoryKind = "on_demand"
)
// LocationExpansion is an open set of location detail expansions.
// The named values were observed in recordings; future values may be passed.
type LocationExpansion string
const (
LocationCapabilities LocationExpansion = "capabilities"
LocationPresentation LocationExpansion = "presentation"
LocationStatus LocationExpansion = "status"
)
type LocationParams struct {
Include []LocationExpansion
}
// TimelineOrder is an open server value. Descending order was recorded.
type TimelineOrder string
const (
TimelineAscending TimelineOrder = "asc"
TimelineDescending TimelineOrder = "desc"
)
// EventCapability is an open server capability token.
type EventCapability string
const (
CapabilityOfflineEvent EventCapability = "offline_event"
CapabilityVehicle EventCapability = "vehicle"
CapabilityRingtercom EventCapability = "ringtercom"
)
type TimelineParams struct {
StartTime *time.Time
EndTime *time.Time
Order *TimelineOrder
Limit *int
Capabilities []EventCapability
}
type HistoryDevicesParams struct {
SourceIDs []string
Capabilities []EventCapability
}
// SignalingTransport is an open transport selector. Only WebSocket is captured.
type SignalingTransport string
const SignalingTransportWebSocket SignalingTransport = "ws"
type CapturedTicketsParams struct {
AllowUserOnly *bool
LocationID *string
LocationSubscription *string
EnableExtendedEmergencyCellUsage *bool
RequestedTransport *SignalingTransport
}
func joinCapabilities(values []EventCapability) *string {
if len(values) == 0 {
return nil
}
parts := make([]string, len(values))
for i, value := range values {
parts[i] = string(value)
}
joined := strings.Join(parts, ",")
return &joined
}
func (p LocationParams) wire() generatedhttp.GetLocationParams {
if len(p.Include) == 0 {
return generatedhttp.GetLocationParams{Include: nil}
}
parts := make([]string, len(p.Include))
for i, value := range p.Include {
parts[i] = string(value)
}
joined := strings.Join(parts, ",")
return generatedhttp.GetLocationParams{Include: &joined}
}
func (p TimelineParams) wire() generatedhttp.GetDeviceTimelineParams {
var order *string
if p.Order != nil {
value := string(*p.Order)
order = &value
}
return generatedhttp.GetDeviceTimelineParams{
StartTime: p.StartTime,
EndTime: p.EndTime,
Order: order,
Limit: p.Limit,
Capabilities: joinCapabilities(p.Capabilities),
}
}
func (p HistoryDevicesParams) wire() generatedhttp.GetHistoryDevicesParams {
var sourceIDs *string
if len(p.SourceIDs) > 0 {
value := strings.Join(p.SourceIDs, ",")
sourceIDs = &value
}
return generatedhttp.GetHistoryDevicesParams{SourceIds: sourceIDs, Capabilities: joinCapabilities(p.Capabilities)}
}
func (p CapturedTicketsParams) wire() generatedhttp.GetCapturedLocationTicketsParams {
var transport *string
if p.RequestedTransport != nil {
value := string(*p.RequestedTransport)
transport = &value
}
return generatedhttp.GetCapturedLocationTicketsParams{
AllowUserOnly: p.AllowUserOnly,
LocationID: p.LocationID,
LocationSubscription: p.LocationSubscription,
EnableExtendedEmergencyCellUsage: p.EnableExtendedEmergencyCellUsage,
RequestedTransport: transport,
}
}
package ring
import (
"encoding/json"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/pkg/ringapimodels"
"github.com/portpowered/go-ring/pkg/ringtypes"
)
// DeviceDetail keeps operation sets that are derived from the provider's
// envelope beside its schema-generated device projection.
type DeviceDetail struct {
Device ringapimodels.DeviceDetailDevice `json:"device"`
OperationSets map[string][]string `json:"-"`
}
// DeviceKind is the schema-backed open hardware kind.
type DeviceKind = ringtypes.DeviceKind
const DeviceKindStickUpMiniPTZ DeviceKind = protocol.DeviceKindStickUpMiniPTZ
type DeviceDetailDevice = ringapimodels.DeviceDetailDevice
type BatteryReading = ringtypes.BatteryReading
type DeviceAlerts = ringapimodels.DeviceAlerts
type ConnectionState = ringtypes.ConnectionState
const (
ConnectionOnline ConnectionState = protocol.ConnectionOnline
ConnectionOffline ConnectionState = protocol.ConnectionOffline
)
type PowerMode = ringtypes.PowerMode
const PowerModeWired PowerMode = protocol.PowerModeWired
type DeviceStatus = ringapimodels.DeviceStatus
type OwnerID = ringtypes.OwnerID
type DeviceOwner = ringapimodels.DeviceOwner
type DeviceLegacySettings = ringapimodels.DeviceLegacySettings
type DeviceDetailHealth = ringapimodels.DeviceDetailHealth
type DeviceFeatures = ringapimodels.DeviceFeatures
type VideoRenderingFeature = ringapimodels.VideoRenderingFeature
type FeatureAvailability = ringapimodels.FeatureAvailability
type FeatureEligibility = ringapimodels.FeatureEligibility
type FeatureEnablement = ringapimodels.FeatureEnablement
type LocationAddress = ringapimodels.LocationAddress
type LocationSummary = ringapimodels.LocationSummary
type GeoCoordinates = ringapimodels.GeoCoordinates
// LocationList keeps operation sets derived from the provider envelope beside
// its schema-generated location projection.
type LocationList struct {
Locations []ringapimodels.LocationSummary `json:"user_locations"`
OperationSets map[string][]string `json:"-"`
}
type LocationAttributes = ringapimodels.LocationAttributes
type LocationResourceType = ringtypes.LocationResourceType
const LocationResourceLocations LocationResourceType = protocol.LocationResourceLocations
type LocationResource = ringapimodels.LocationResource
type LocationDetail = ringapimodels.LocationDetail
type LocationMeta = ringapimodels.LocationMeta
type LocationGroup = ringapimodels.LocationGroup
type LocationGroups = ringapimodels.LocationGroups
type LocationGroupDevices = ringapimodels.LocationGroupDevices
type TimelineEventType = ringtypes.TimelineEventType
const (
TimelineEventOnDemand TimelineEventType = protocol.TimelineEventOnDemand
TimelineEventDing TimelineEventType = protocol.TimelineEventDing
TimelineEventMotion TimelineEventType = protocol.TimelineEventMotion
)
type RecordingStatus = ringtypes.RecordingStatus
const RecordingStatusReady RecordingStatus = protocol.RecordingStatusReady
type TimelineState = ringtypes.TimelineState
const TimelineStateCompleted TimelineState = protocol.TimelineStateCompleted
type TimelineDevice = ringapimodels.TimelineDevice
type TimelineEvent = ringapimodels.TimelineEvent
type DeviceTimeline = ringapimodels.DeviceTimeline
type HistoryFeedItem = ringapimodels.HistoryFeedItem
type HistoryFeedType = ringtypes.HistoryFeedType
const HistoryFeedEvent HistoryFeedType = protocol.HistoryFeedEvent
type HistoryDevices = ringapimodels.HistoryDevices
type CapturedTickets = ringapimodels.CapturedTickets
// projectCaptured creates the SDK projection after the generated wire decoder
// has validated the captured response. Unknown fields do not enter the API.
func projectCaptured[T any](wire any) (*T, error) {
encoded, err := json.Marshal(wire)
if err != nil {
return nil, ringapimodels.NewInternalServerError("failed to project captured response", err)
}
var result T
{
err := json.Unmarshal(encoded, &result)
if err != nil {
return nil, ringapimodels.NewInternalServerError("failed to project captured response", err)
}
}
return &result, nil
}
package ring
import (
"context"
"strconv"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// GetDeviceHistory retrieves the history of recordings for a device.
func (c *Client) GetDeviceHistory(
ctx context.Context,
req GetDeviceHistoryRequest,
) (*ringapimodels.RecordingHistoryResponse, error) {
ctx = c.accountContext(ctx, req.Auth)
deviceIDInt, err := strconv.ParseInt(req.DeviceID, 10, 64)
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid device ID format", err)
}
if req.OlderThan != nil && *req.OlderThan < 0 {
return nil, ringapimodels.NewBadRequestError("older-than cursor must be nonnegative", nil)
}
rawResponse, err := c.restClient.GetDeviceHistory(ctx, deviceIDInt, req.Limit, string(req.Kind), req.OlderThan)
if err != nil {
return nil, err
}
response := &ringapimodels.RecordingHistoryResponse{Recordings: nil}
for _, raw := range rawResponse {
recording := ringapimodels.Recording{
ID: int64(raw.Id),
Kind: raw.Kind,
Answered: raw.Answered,
CreatedAt: raw.CreatedAt,
DeviceID: int64(raw.Doorbot.Id),
}
response.Recordings = append(response.Recordings, recording)
}
return response, nil
}
// GetActiveDings retrieves currently active dings.
func (c *Client) GetActiveDings(
ctx context.Context,
req GetActiveDingsRequest,
) (*ringapimodels.RecordingHistoryResponse, error) {
ctx = c.accountContext(ctx, req.Auth)
rawResponse, err := c.restClient.GetActiveDings(ctx)
if err != nil {
return nil, err
}
response := &ringapimodels.RecordingHistoryResponse{Recordings: nil}
for _, raw := range rawResponse {
recording := ringapimodels.Recording{
ID: int64(raw.Id),
Kind: raw.Kind,
Answered: raw.Answered,
CreatedAt: raw.CreatedAt,
DeviceID: int64(raw.Doorbot.Id),
}
response.Recordings = append(response.Recordings, recording)
}
return response, nil
}
// GetRecording retrieves a video stream for a recording.
func (c *Client) GetRecording(ctx context.Context, req GetRecordingRequest) (*ringapimodels.VideoStream, error) {
ctx = c.accountContext(ctx, req.Auth)
return c.restClient.GetRecording(ctx, req.RecordingID)
}
// GetRecordingShareURL returns the legacy playback share URL for a recording.
// Access permissions and subscription checks are resolved by the server.
func (c *Client) GetRecordingShareURL(ctx context.Context, req GetRecordingShareURLRequest) (string, error) {
ctx = c.accountContext(ctx, req.Auth)
if req.RecordingID <= 0 {
return "", ringapimodels.NewBadRequestError("invalid recording ID", nil)
}
return c.restClient.GetRecordingShareURL(ctx, req.RecordingID)
}
// GetLastRecordingID retrieves the ID of the most recent recording for a device.
func (c *Client) GetLastRecordingID(ctx context.Context, req GetLastRecordingIDRequest) (int64, error) {
ctx = c.accountContext(ctx, req.Auth)
history, err := c.GetDeviceHistory(ctx, GetDeviceHistoryRequest{
Auth: req.Auth,
DeviceID: req.DeviceID,
Limit: 1,
Kind: "",
OlderThan: nil,
})
if err != nil {
return 0, err
}
if len(history.Recordings) == 0 {
return 0, ringapimodels.NewNotFoundError("no recordings found", nil)
}
return history.Recordings[0].ID, nil
}
package ring
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"github.com/google/uuid"
"github.com/portpowered/go-ring/internal/generatedhttp"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/signaling"
dependencywebsocket "github.com/portpowered/go-ring/pkg/dependencies/websocket"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// OpenSignaling obtains the currently supported legacy signaling ticket and opens its websocket.
// The captured C1 GET /api/v1/clap/tickets profile is intentionally not substituted for this POST route.
func (c *Client) OpenSignaling(ctx context.Context, req OpenSignalingRequest) (*SignalingConnection, error) {
ctx = c.accountContext(ctx, req.Auth)
err := ctx.Err()
if err != nil {
return nil, ringapimodels.NewConnectionError("signaling open canceled", err)
}
ticket, err := c.requestSignalingTicket(ctx)
if err != nil {
return nil, err
}
clientID := uuid.NewString()
wsURL := strings.Replace(c.signalingWebSocketURL, "{client_id}", clientID, 1)
wsURL = strings.Replace(wsURL, "{token}", url.QueryEscape(ticket), 1)
if c.signalingWebSocketURL == protocol.SignalingURL {
err = protocol.ValidateWebSocketURL(protocol.SignalingChannel, wsURL)
} else {
err = protocol.ValidateWebSocketOverride(wsURL)
}
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid signaling WebSocket URL", err)
}
wsHeaders := http.Header{}
wsHeaders.Set(string(generatedhttp.UserAgent), c.userAgent)
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
wsHeaders.Set(string(generatedhttp.HardwareId), hardwareID)
}
conn, err := dependencywebsocket.DialSignaling(ctx, wsURL, wsHeaders, c.websocketDialer)
if err != nil {
// Dialer errors may contain the ticket-bearing URL.
return nil, ringapimodels.NewConnectionError("failed to connect to signaling websocket", nil)
}
connCtx, cancel := context.WithCancel(ctx)
signalingConnection := &SignalingConnection{
conn: conn,
ctx: connCtx,
cancel: cancel,
done: make(chan struct{}),
readerDone: make(chan struct{}),
pending: make(map[string]chan signaling.Message),
sessions: make(map[string]*DeviceSession),
channels: make(map[string]chan signaling.Message),
playbacks: make(map[string]*PlaybackSession),
pushes: make(map[string]*PushSubscription),
}
signalingConnection.writer = dependencywebsocket.NewSignalingWriter(
signalingConnection.done,
signalingConnection.writeFrame,
func(err error) {
if ringapimodels.IsConnectionError(err) {
signalingConnection.fail(err)
return
}
signalingConnection.fail(ringapimodels.NewConnectionError("signaling write failed", err))
},
)
go signalingConnection.readLoop()
go signalingConnection.writer.Run()
go func() {
select {
case <-ctx.Done():
signalingConnection.fail(ctx.Err())
case <-signalingConnection.done:
}
}()
return signalingConnection, nil
}
func (c *Client) requestSignalingTicket(ctx context.Context) (string, error) {
err := c.ensureSession(ctx)
if err != nil {
return "", ringapimodels.NewConnectionError("failed to register Ring session", err)
}
token, err := c.getToken(ctx)
if err != nil {
return "", ringapimodels.NewTokenError("failed to get token for signaling", err)
}
if c.endpoints.SolutionsBaseURL == "" {
return "", ringapimodels.NewConnectionError(
"Solutions bootstrap URL is unverified for selected region; configure WithEndpoints",
nil,
)
}
headers := http.Header{}
headers.Set(string(generatedhttp.Authorization), "Bearer "+token)
headers.Set(string(generatedhttp.UserAgent), c.userAgent)
headers.Set(string(generatedhttp.ContentType), "application/json")
if hardwareID := c.hardwareIDFor(ctx); hardwareID != "" {
headers.Set(string(generatedhttp.HardwareId), hardwareID)
}
wire, err := generatedhttp.NewClient(
c.endpoints.SolutionsBaseURL,
generatedhttp.WithHTTPClient(c.restClient.HTTPClient()),
)
if err != nil {
return "", ringapimodels.NewNetworkError("failed to configure signaling ticket client", err)
}
response, err := wire.RequestLegacySignalingTicket(ctx, func(_ context.Context, request *http.Request) error {
request.Header = headers.Clone()
return nil
})
if err != nil {
return "", ringapimodels.NewNetworkError("failed to request signaling ticket", err)
}
responseBytes, readErr := io.ReadAll(io.LimitReader(response.Body, signaling.MaxTicketResponseBytes+1))
_ = response.Body.Close()
if readErr != nil {
return "", ringapimodels.NewNetworkError("failed to read signaling ticket response", readErr)
}
if len(responseBytes) > signaling.MaxTicketResponseBytes {
return "", ringapimodels.NewInternalServerError("signaling ticket response exceeds size limit", nil)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", ringapimodels.ClassifyHTTPError(response, string(responseBytes))
}
var ticket generatedhttp.LegacySignalingTicket
err = json.Unmarshal(responseBytes, &ticket)
if err != nil {
return "", ringapimodels.NewInternalServerError("invalid signaling ticket response", err)
}
if ticket.Ticket == "" {
return "", ringapimodels.NewConnectionError("empty signaling ticket", nil)
}
return ticket.Ticket, nil
}
package ring
import (
"context"
"strconv"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// GetDeviceSettings reads the motion_detection_enabled field from the captured
// v1 settings resource. Unrecognized settings are ignored.
func (c *Client) GetDeviceSettings(ctx context.Context, req GetDeviceSettingsRequest) (DeviceSettings, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return DeviceSettings{}, err
}
enabled, err := c.restClient.GetMotionDetectionEnabled(ctx, id)
if err != nil {
return DeviceSettings{}, err
}
return DeviceSettings{MotionDetectionEnabled: enabled}, nil
}
// PatchDeviceSettings updates only the supported motion detection flag. A nil
// field is rejected instead of silently sending an empty or guessed patch.
func (c *Client) PatchDeviceSettings(ctx context.Context, req PatchDeviceSettingsRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
if req.MotionDetectionEnabled == nil {
return ringapimodels.NewBadRequestError("no supported settings fields were supplied", nil)
}
return c.restClient.PatchMotionDetectionEnabled(ctx, id, *req.MotionDetectionEnabled)
}
// SetSiren calls the captured on/off doorbot route. The captured server response
// reports 30 seconds for the on call, but the request does not accept duration.
func (c *Client) SetSiren(ctx context.Context, req SetSirenRequest) error {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return err
}
return c.restClient.SetSiren(ctx, id, req.Enabled)
}
func settingsDeviceID(value string) (int64, error) {
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return 0, ringapimodels.NewBadRequestError("invalid device ID", err)
}
return id, nil
}
package ring
import (
"context"
"time"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
var ErrSnapshotNotReady = ringapimodels.NewNotFoundError("fresh snapshot not available", nil)
// GetSnapshot triggers a fresh legacy snapshot, polls its timestamp, then
// fetches the image. This follows pinned Python behavior; it is not the C1
// app-snaps endpoint, whose response was absent from the capture.
func (c *Client) GetSnapshot(ctx context.Context, req GetSnapshotRequest) (*Snapshot, error) {
ctx = c.accountContext(ctx, req.Auth)
id, err := settingsDeviceID(req.DeviceID)
if err != nil {
return nil, err
}
if req.MaxAttempts < 0 || req.MaxAttempts > 100 || req.PollInterval < 0 {
return nil, ringapimodels.NewBadRequestError("invalid snapshot polling bounds", nil)
}
attempts := req.MaxAttempts
if attempts == 0 {
attempts = 3
}
{
_, err = c.restClient.RefreshSnapshotTimestamp(ctx, id)
if err != nil {
return nil, err
}
}
requestedAt := time.Now().UnixMilli()
for range attempts {
if req.PollInterval > 0 {
timer := time.NewTimer(req.PollInterval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ringapimodels.NewNetworkError("snapshot polling canceled", ctx.Err())
case <-timer.C:
}
}
timestamp, err := c.restClient.RefreshSnapshotTimestamp(ctx, id)
if err != nil {
return nil, err
}
if timestamp <= requestedAt {
continue
}
data, contentType, err := c.restClient.GetSnapshotImage(ctx, id)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, ringapimodels.NewInternalServerError("snapshot image was empty", nil)
}
return &Snapshot{Bytes: data, Timestamp: time.UnixMilli(timestamp), ContentType: contentType}, nil
}
return nil, ErrSnapshotNotReady
}
package ring
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"sync"
"time"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/signaling"
"github.com/portpowered/go-ring/pkg/dependencies/webrtc"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
func (c *SignalingConnection) waitForCameraStarted(
ctx, negotiationCtx context.Context,
events <-chan signaling.Message,
session *DeviceSession,
deadlineError func() error,
) error {
for {
select {
case message := <-events:
if message.Method == protocol.MethodSessionCreated || message.Method == protocol.MethodSDP {
continue
}
err := session.core.Handle(message)
if err != nil {
return sessionError("session activation message rejected", err)
}
if message.Method == protocol.MethodClose && session.matches(message) {
return ringapimodels.NewClosedError("device session closed before activation", signaling.ErrClosed)
}
if message.Method == protocol.MethodCameraStarted && session.matches(message) {
close(session.ready)
return nil
}
case <-negotiationCtx.Done():
return ringapimodels.NewConnectionError("session activation failed", deadlineError())
case <-ctx.Done():
return sessionError("session activation canceled", ctx.Err())
case <-c.done:
return c.Err()
case <-session.done:
return session.terminalError()
}
}
}
func validateSessionRequest(req StartDeviceSessionRequest) (int64, time.Duration, error) {
id, err := strconv.ParseInt(req.DeviceID, 10, 64)
if err != nil || id <= 0 {
return 0, 0, ringapimodels.NewBadRequestError("device ID must be a positive integer", err)
}
if req.Offer.Type != SDPTypeOffer || req.Offer.SDP == "" {
return 0, 0, ringapimodels.NewBadRequestError("offer must contain type offer and SDP", nil)
}
if req.ICEMode != "" && req.ICEMode != ICETrickle && req.ICEMode != ICENonTrickle {
return 0, 0, ringapimodels.NewBadRequestError(
fmt.Sprintf("unsupported ICE candidate mode %q", req.ICEMode),
nil,
)
}
{
_, err = webrtc.ParseSDP(req.Offer.SDP)
if err != nil {
return 0, 0, ringapimodels.NewBadRequestError("invalid SDP offer", err)
}
}
maxAge := req.MaxAge
if maxAge == 0 {
maxAge = signaling.MaxSessionAge
}
if maxAge <= 0 || maxAge > signaling.MaxSessionAge {
return 0, 0, ringapimodels.NewBadRequestError("maximum session age must be between zero and sixty minutes", nil)
}
return id, maxAge, nil
}
func (c *SignalingConnection) StartDeviceSession(
ctx context.Context,
req StartDeviceSessionRequest,
) (*DeviceSession, error) {
started := time.Now()
err := ctx.Err()
if err != nil {
return nil, ringapimodels.NewConnectionError("session start canceled", err)
}
negotiation, negotiationCtx, err := c.prepareDeviceSession(ctx, req, started)
if err != nil {
return nil, err
}
defer negotiation.cancel()
return c.finishDeviceSession(ctx, negotiationCtx, req, negotiation)
}
func (c *SignalingConnection) activateDeviceSession(
ctx context.Context,
session *DeviceSession,
deviceID int64,
signalID string,
req StartDeviceSessionRequest,
) error {
err := c.send(
ctx,
signaling.Message{
Method: protocol.MethodActivateSession,
DialogID: session.dialogID,
RIID: session.riid,
Body: mustJSON(generatedsignaling.SessionBody{
DoorbotId: int(deviceID),
SessionId: signalID,
AdditionalProperties: nil,
}),
},
)
if err != nil {
return sessionError("failed to activate device session", err)
}
err = session.core.Send(ctx, protocol.MethodMicEnable, mustJSON(generatedsignaling.SessionMicrophoneBody{
DoorbotId: int(deviceID),
SessionId: signalID,
Enabled: req.AudioEnabled,
AdditionalProperties: nil,
}))
if err != nil {
return sessionError("failed to set microphone state", err)
}
err = session.core.Send(ctx, protocol.MethodStreamOptions, mustJSON(generatedsignaling.SessionStreamAudioOptionsBody{
DoorbotId: int(deviceID),
SessionId: signalID,
AudioEnabled: req.AudioEnabled,
}))
if err != nil {
return sessionError("failed to set stream options", err)
}
return nil
}
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}
func (s *DeviceSession) watch(ctx context.Context) {
err := s.core.Wait(ctx)
if errors.Is(err, signaling.ErrExpired) || errors.Is(err, signaling.ErrHeartbeat) {
// This bounded close must outlive the context whose expiry ended the session.
ctx, cancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
//nolint:contextcheck // Session teardown sends a final close after the parent context may be canceled.
_ = s.connection.send(
ctx,
signaling.Message{
Method: protocol.MethodClose,
DialogID: s.dialogID,
RIID: s.riid,
Body: mustJSON(generatedsignaling.SessionBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
AdditionalProperties: nil,
}),
},
)
cancel()
}
s.mu.Lock()
if !s.closed {
s.closed = true
s.terminal = sessionError("device session ended", err)
}
s.mu.Unlock()
s.connection.removeSession(s.dialogID)
s.doneOnce.Do(func() { close(s.done) })
}
func (s *DeviceSession) matches(m signaling.Message) bool {
var body generatedsignaling.SessionBody
return m.DialogID == s.dialogID && json.Unmarshal(m.Body, &body) == nil && int64(body.DoorbotId) == s.deviceID &&
body.SessionId == s.signalID
}
func (s *DeviceSession) handle(message signaling.Message) {
err := s.core.Handle(message)
if err != nil {
s.terminate(err)
return
}
if message.Method == protocol.MethodClose && s.matches(message) {
s.terminate(signaling.ErrClosed)
}
}
func (s *DeviceSession) terminate(err error) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.terminal = sessionError("device session ended", err)
s.mu.Unlock()
s.core.Fail(err)
}
func (s *DeviceSession) Answer() SessionDescription { return s.answer }
func (s *DeviceSession) State() SessionState {
s.mu.Lock()
closed, err := s.closed, s.terminal
s.mu.Unlock()
if !closed {
return SessionActive
}
if errors.Is(err, signaling.ErrExpired) {
return SessionExpired
}
if err != nil && !errors.Is(err, signaling.ErrClosed) {
return SessionFailed
}
return SessionClosed
}
func (s *DeviceSession) Wait(ctx context.Context) error {
err := s.core.Wait(ctx)
if ctx.Err() != nil {
return sessionError("device session wait canceled", ctx.Err())
}
// Wait for the public wrapper to publish the terminal state and finish its
// bounded signaling close after the core has observed expiry or failure.
select {
case <-s.done:
case <-ctx.Done():
return sessionError("device session wait canceled", ctx.Err())
}
return sessionError("device session ended", err)
}
func (s *DeviceSession) terminalError() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.terminal
}
func (s *DeviceSession) Receive(ctx context.Context) (*SessionEvent, error) {
m, e := s.core.Receive(ctx)
if e != nil {
return nil, sessionError("device session receive failed", e)
}
return &SessionEvent{Method: m.Method, Body: m.Body}, nil
}
func (s *DeviceSession) SendICE(ctx context.Context, req ICECandidateRequest) error {
if s.iceMode != ICETrickle {
return ringapimodels.NewBadRequestError("SendICE requires trickle ICE mode", nil)
}
desc, parseErr := webrtc.ParseSDP(s.offerSDP)
if parseErr != nil {
return ringapimodels.NewInternalServerError("stored offer SDP is invalid", parseErr)
}
{
parseErr = webrtc.ValidateICE(desc, req.MID, req.MLineIndex)
if parseErr != nil {
return ringapimodels.NewBadRequestError("invalid ICE candidate media identity", parseErr)
}
}
if req.Candidate == "" {
return ringapimodels.NewBadRequestError("ICE candidate must not be empty", nil)
}
body := generatedsignaling.LiveIceBody{
DoorbotId: int(s.deviceID),
Ice: req.Candidate,
Mid: req.MID,
Mlineindex: req.MLineIndex,
AdditionalProperties: nil,
}
return s.connection.send(
ctx,
signaling.Message{Method: protocol.MethodICE, DialogID: s.dialogID, RIID: s.riid, Body: mustJSON(body)},
)
}
func (s *DeviceSession) call(ctx context.Context, method string, direction string, speed *float64) (*PTZResult, error) {
wireDirection, ok := generatedsignaling.ValuesToPtzDirection[direction]
if !ok {
return nil, ringapimodels.NewBadRequestError("invalid PTZ direction", nil)
}
resultBytes, e := s.core.Call(ctx, method, wireDirection, speed)
if e != nil {
return nil, sessionError("PTZ command failed", e)
}
var result PTZResult
err := json.Unmarshal(resultBytes, &result)
if err != nil {
return nil, ringapimodels.NewConnectionError("invalid PTZ result", err)
}
result.Raw = append(json.RawMessage(nil), resultBytes...)
return &result, nil
}
func (s *DeviceSession) PanStep(ctx context.Context, r PanStepRequest) (*PTZResult, error) {
if r.Direction != PanLeft && r.Direction != PanRight {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid pan direction %q", r.Direction), nil)
}
return s.call(ctx, protocol.RPCPanStep, string(r.Direction), nil)
}
func (s *DeviceSession) TiltStep(ctx context.Context, r TiltStepRequest) (*PTZResult, error) {
if r.Direction != TiltUp && r.Direction != TiltDown {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid tilt direction %q", r.Direction), nil)
}
return s.call(ctx, protocol.RPCTiltStep, string(r.Direction), nil)
}
func (s *DeviceSession) PanContinuous(ctx context.Context, r PanContinuousRequest) (*PTZResult, error) {
if r.Direction != PanLeft && r.Direction != PanRight {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid pan direction %q", r.Direction), nil)
}
return s.continuous(ctx, PanAxis, string(r.Direction), r.Speed, protocol.RPCPanContinuous)
}
func (s *DeviceSession) TiltContinuous(ctx context.Context, r TiltContinuousRequest) (*PTZResult, error) {
if r.Direction != TiltUp && r.Direction != TiltDown {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid tilt direction %q", r.Direction), nil)
}
return s.continuous(ctx, TiltAxis, string(r.Direction), r.Speed, protocol.RPCTiltContinuous)
}
func (s *DeviceSession) continuous(
ctx context.Context,
axis PTZAxis,
direction string,
speed float64,
method string,
) (*PTZResult, error) {
if speed < 0 || speed > protocol.PTZMaxSpeed || math.IsNaN(speed) || math.IsInf(speed, 0) {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid %s speed", axis), nil)
}
axisMu := &s.panMu
if axis == TiltAxis {
axisMu = &s.tiltMu
}
axisMu.Lock()
defer axisMu.Unlock()
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, ringapimodels.NewClosedError("device session is closed", signaling.ErrClosed)
}
previousDirection, hadPrevious := s.movement[axis]
s.movement[axis] = direction
s.mu.Unlock()
result, err := s.call(ctx, method, direction, &speed)
if err != nil {
s.mu.Lock()
if hadPrevious {
s.movement[axis] = previousDirection
} else {
delete(s.movement, axis)
}
s.mu.Unlock()
}
return result, err
}
func (s *DeviceSession) StopPTZ(ctx context.Context, request StopPTZRequest) (*PTZResult, error) {
var axisMu *sync.Mutex
switch request.Axis {
case PanAxis:
axisMu = &s.panMu
case TiltAxis:
axisMu = &s.tiltMu
default:
return nil, ringapimodels.NewBadRequestError("invalid PTZ axis", nil)
}
axisMu.Lock()
defer axisMu.Unlock()
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil, ringapimodels.NewClosedError("device session is closed", signaling.ErrClosed)
}
direction := s.movement[request.Axis]
s.mu.Unlock()
if direction == "" {
return nil, ringapimodels.NewBadRequestError(
fmt.Sprintf("no tracked continuous movement for axis %q", request.Axis),
nil,
)
}
method := protocol.RPCPanContinuous
if request.Axis == TiltAxis {
method = protocol.RPCTiltContinuous
}
stopSpeed := 0.0
result, callErr := s.call(ctx, method, direction, &stopSpeed)
if callErr == nil {
s.mu.Lock()
delete(s.movement, request.Axis)
s.mu.Unlock()
}
return result, callErr
}
func (s *DeviceSession) SetMicrophone(ctx context.Context, r SetMicrophoneRequest) error {
body := generatedsignaling.SessionMicrophoneBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
Enabled: r.Enabled,
AdditionalProperties: nil,
}
return sessionError("microphone command failed", s.core.Send(ctx, protocol.MethodMicEnable, mustJSON(body)))
}
func (s *DeviceSession) SetStreamOptions(ctx context.Context, request SetStreamOptionsRequest) error {
if request.AudioEnabled == nil && request.VideoEnabled == nil {
return ringapimodels.NewBadRequestError("at least one stream option is required", nil)
}
var body json.RawMessage
switch {
case request.AudioEnabled != nil && request.VideoEnabled != nil:
body = mustJSON(generatedsignaling.SessionStreamAudioVideoOptionsBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
AudioEnabled: *request.AudioEnabled,
VideoEnabled: *request.VideoEnabled,
})
case request.AudioEnabled != nil:
body = mustJSON(generatedsignaling.SessionStreamAudioOptionsBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
AudioEnabled: *request.AudioEnabled,
})
case request.VideoEnabled != nil:
body = mustJSON(generatedsignaling.SessionStreamVideoOptionsBody{
DoorbotId: int(s.deviceID),
SessionId: s.signalID,
VideoEnabled: *request.VideoEnabled,
})
}
return sessionError("stream-options command failed", s.core.Send(ctx, protocol.MethodStreamOptions, body))
}
func (s *DeviceSession) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
defer cancel()
s.closeWithContext(ctx, true)
return nil
}
func (s *DeviceSession) closeWithContext(ctx context.Context, sendClose bool) {
s.panMu.Lock()
s.tiltMu.Lock()
s.mu.Lock()
if s.closed {
s.mu.Unlock()
s.tiltMu.Unlock()
s.panMu.Unlock()
return
}
s.closed = true
s.terminal = signaling.ErrClosed
movements := make(map[PTZAxis]string, len(s.movement))
for axis, direction := range s.movement {
movements[axis] = direction
}
s.movement = make(map[PTZAxis]string)
s.mu.Unlock()
s.tiltMu.Unlock()
s.panMu.Unlock()
if sendClose {
// Stop each tracked continuous move before closing the signaling session.
// Both RPC acknowledgements and the final close share one short best-effort budget.
for axis, direction := range movements {
method := protocol.RPCPanContinuous
if axis == TiltAxis {
method = protocol.RPCTiltContinuous
}
stopSpeed := 0.0
_, _ = s.call(ctx, method, direction, &stopSpeed)
}
_ = s.core.Send(ctx, protocol.MethodClose, nil)
}
_ = s.core.Close()
s.connection.removeSession(s.dialogID)
s.doneOnce.Do(func() { close(s.done) })
}
package ring
import (
"context"
"time"
"github.com/google/uuid"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/signaling"
"github.com/portpowered/go-ring/pkg/dependencies/webrtc"
dependencywebsocket "github.com/portpowered/go-ring/pkg/dependencies/websocket"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
type deviceSessionNegotiation struct {
started time.Time
id int64
maxAge time.Duration
cancel context.CancelFunc
dialog string
events chan signaling.Message
signalID string
riid string
startedSuccess bool
deadlineError func() error
}
func (c *SignalingConnection) prepareDeviceSession(
ctx context.Context,
req StartDeviceSessionRequest,
started time.Time,
) (*deviceSessionNegotiation, context.Context, error) {
id, maxAge, err := validateSessionRequest(req)
if err != nil {
return nil, nil, err
}
negotiationBudget := min(maxAge, signaling.NegotiationTimeout)
negotiationCtx, cancel := context.WithDeadline(ctx, started.Add(negotiationBudget))
deadlineError := func() error {
if !time.Now().Before(started.Add(maxAge)) {
return signaling.ErrExpired
}
return negotiationCtx.Err()
}
negotiation := &deviceSessionNegotiation{
started: started,
id: id,
maxAge: maxAge,
cancel: cancel,
dialog: uuid.NewString(),
events: make(chan signaling.Message, signaling.NegotiationQueueCapacity),
signalID: "",
riid: "",
startedSuccess: false,
deadlineError: deadlineError,
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
cancel()
return nil, nil, c.Err()
}
c.pending[negotiation.dialog] = negotiation.events
c.mu.Unlock()
body := generatedsignaling.LiveViewBody{
DoorbotId: int(negotiation.id),
StreamOptions: &generatedsignaling.LiveStreamOptions{
AudioEnabled: req.AudioEnabled,
VideoEnabled: req.VideoEnabled,
AdditionalProperties: nil,
},
Sdp: req.Offer.SDP,
ReservedType: protocol.SDPTypeOffer,
AdditionalProperties: nil,
}
err = c.send(negotiationCtx, signaling.Message{
Method: protocol.MethodLiveView,
DialogID: negotiation.dialog,
RIID: "",
Body: mustJSON(body),
})
if err != nil {
negotiation.cleanup(c)
cancel()
return nil, nil, sessionError("failed to send live-view offer", err)
}
return negotiation, negotiationCtx, nil
}
func (negotiation *deviceSessionNegotiation) cleanup(connection *SignalingConnection) {
connection.mu.Lock()
delete(connection.pending, negotiation.dialog)
connection.mu.Unlock()
}
func (c *SignalingConnection) finishDeviceSession(
ctx, negotiationCtx context.Context,
req StartDeviceSessionRequest,
negotiation *deviceSessionNegotiation,
) (*DeviceSession, error) {
//nolint:contextcheck // Failure cleanup sends a bounded close frame after negotiation cancellation.
defer func() {
if !negotiation.startedSuccess && negotiation.signalID != "" {
negotiation.close(c)
}
}()
created, err := c.createNegotiatedDeviceSession(ctx, negotiationCtx, req, negotiation)
if err != nil {
negotiation.cleanup(c)
return nil, err
}
err = c.startAndRegisterDeviceSession(ctx, negotiationCtx, req, negotiation, created)
if err != nil {
created.terminate(err)
negotiation.cleanup(c)
return nil, err
}
negotiation.startedSuccess = true
return created, nil
}
func (negotiation *deviceSessionNegotiation) close(connection *SignalingConnection) {
ctx, cancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
defer cancel()
_ = connection.send(
ctx,
signaling.Message{
Method: protocol.MethodClose,
DialogID: negotiation.dialog,
RIID: negotiation.riid,
Body: mustJSON(generatedsignaling.SessionBody{
DoorbotId: int(negotiation.id),
SessionId: negotiation.signalID,
AdditionalProperties: nil,
}),
},
)
}
func (c *SignalingConnection) createNegotiatedDeviceSession(
ctx, negotiationCtx context.Context,
req StartDeviceSessionRequest,
negotiation *deviceSessionNegotiation,
) (*DeviceSession, error) {
negotiated, err := dependencywebsocket.AwaitLiveAnswer(
negotiationCtx,
c.done,
c.Err,
negotiation.events,
negotiation.id,
negotiation.deadlineError,
)
negotiation.signalID, negotiation.riid = negotiated.SignalID, negotiated.RIID
if err != nil {
return nil, sessionError("live-view negotiation failed", err)
}
answerSDP, controlID, heartbeat := negotiated.AnswerSDP, negotiated.ControlID, negotiated.Heartbeat
if controlID == "" || controlID == negotiation.signalID {
return nil, ringapimodels.NewConnectionError("answer is missing an independent PTZ session identity", nil)
}
answer, err := webrtc.NormalizeAnswer(req.Offer.SDP, answerSDP)
if err != nil {
return nil, ringapimodels.NewConnectionError("invalid SDP answer", err)
}
_, err = webrtc.ParseSDP(answer)
if err != nil {
return nil, ringapimodels.NewConnectionError("invalid SDP answer", err)
}
iceMode := req.ICEMode
if iceMode == "" {
iceMode = ICETrickle
}
created := &DeviceSession{
connection: c,
dialogID: negotiation.dialog,
answer: SessionDescription{Type: SDPTypeAnswer, SDP: answer},
offerSDP: req.Offer.SDP,
started: negotiation.started,
movement: map[PTZAxis]string{},
ready: make(chan struct{}),
done: make(chan struct{}),
deviceID: negotiation.id,
signalID: negotiation.signalID,
riid: negotiation.riid,
iceMode: iceMode,
}
remaining := negotiation.maxAge - time.Since(negotiation.started)
if remaining <= 0 {
return nil, ringapimodels.NewConnectionError("session expired during negotiation", signaling.ErrExpired)
}
created.core, err = signaling.NewSession(
ctx,
signaling.SessionConfig{
DeviceID: negotiation.id,
DialogID: negotiation.dialog,
SignalID: negotiation.signalID,
ControlID: controlID,
Heartbeat: heartbeat,
MaxAge: remaining,
Clock: c.clock,
Send: func(ctx context.Context, message signaling.Message) error {
if message.RIID == "" {
message.RIID = negotiation.riid
}
return c.send(ctx, message)
},
},
)
if err != nil {
return nil, sessionError("failed to create device session", err)
}
go created.watch(ctx)
return created, nil
}
func (c *SignalingConnection) startAndRegisterDeviceSession(
ctx, negotiationCtx context.Context,
req StartDeviceSessionRequest,
negotiation *deviceSessionNegotiation,
created *DeviceSession,
) error {
err := c.activateDeviceSession(negotiationCtx, created, negotiation.id, negotiation.signalID, req)
if err != nil {
return err
}
err = c.waitForCameraStarted(
ctx,
negotiationCtx,
negotiation.events,
created,
negotiation.deadlineError,
)
if err != nil {
return sessionError("device session did not become ready", err)
}
c.mu.Lock()
delete(c.pending, negotiation.dialog)
if c.closed {
c.mu.Unlock()
return c.Err()
}
c.sessions[negotiation.dialog] = created
c.mu.Unlock()
for {
select {
case message := <-negotiation.events:
created.handle(message)
default:
return nil
}
}
}
package ring
import (
"fmt"
"net/url"
"strings"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/pkg/dependencies/rest"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
type endpointValidation struct {
name, raw string
schemes []string
queryOK bool
}
func validateEndpoints(e Endpoints) error {
for _, endpoint := range []endpointValidation{
{"OAuthBaseURL", e.OAuthBaseURL, []string{"http", "https"}, false},
{"APIBaseURL", e.APIBaseURL, []string{"http", "https"}, false},
{"SolutionsBaseURL", e.SolutionsBaseURL, []string{"http", "https"}, false},
{"SignalingURL", e.SignalingURL, []string{"ws", "wss"}, true},
} {
if endpoint.raw == "" {
continue
}
err := validateEndpointURL(endpoint)
if err != nil {
return err
}
}
return nil
}
func validateEventWebSocketURL(raw string) error {
return validateEndpointURL(endpointValidation{
name: "EventWebSocketURL", raw: raw, schemes: []string{"ws", "wss"}, queryOK: true,
})
}
func validateEndpointURL(endpoint endpointValidation) error {
parsedURL, err := url.Parse(endpoint.raw)
validScheme := false
for _, scheme := range endpoint.schemes {
validScheme = validScheme || (parsedURL != nil && parsedURL.Scheme == scheme)
}
if err != nil ||
parsedURL == nil ||
parsedURL.Host == "" ||
parsedURL.User != nil ||
(!endpoint.queryOK && parsedURL.RawQuery != "") ||
parsedURL.Fragment != "" ||
parsedURL.Opaque != "" ||
!validScheme {
return ringapimodels.NewBadRequestError(
fmt.Sprintf("invalid %s URL %q", endpoint.name, endpoint.raw),
err,
)
}
return nil
}
func (c *Client) applyEndpointConfiguration() error {
p := protocol.Profile(string(c.region))
endpoints := Endpoints{p.OAuthBaseURL, p.APIBaseURL, p.SolutionsBaseURL, p.SignalingURL}
overrides := c.endpointOverrides
if overrides.OAuthBaseURL != "" {
endpoints.OAuthBaseURL = strings.TrimRight(overrides.OAuthBaseURL, "/")
}
if overrides.APIBaseURL != "" {
endpoints.APIBaseURL = strings.TrimRight(overrides.APIBaseURL, "/")
}
if overrides.SolutionsBaseURL != "" {
endpoints.SolutionsBaseURL = strings.TrimRight(overrides.SolutionsBaseURL, "/")
}
if overrides.SignalingURL != "" {
endpoints.SignalingURL = overrides.SignalingURL
}
if c.signalingWebSocketOverride != "" {
endpoints.SignalingURL = c.signalingWebSocketOverride
}
c.endpoints = endpoints
c.signalingWebSocketURL = endpoints.SignalingURL
c.restClient.Apply(rest.WithEndpointBases(endpoints.APIBaseURL, endpoints.OAuthBaseURL))
return nil
}
package ring
import (
"errors"
"github.com/portpowered/go-ring/internal/signaling"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
func sessionError(message string, err error) error {
if err == nil {
return nil
}
if ringapimodels.IsClosedError(err) || ringapimodels.IsConnectionError(err) || ringapimodels.IsBadRequestError(err) {
return err
}
if errors.Is(err, signaling.ErrClosed) {
return ringapimodels.NewClosedError(message, err)
}
return ringapimodels.NewConnectionError(message, err)
}
package ring
import (
"context"
"sync"
"github.com/google/uuid"
"github.com/portpowered/go-ring/pkg/dependencies/rest"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// LoginSession owns one customer's PKCE challenge and cookies. Separate login
// sessions may run concurrently on the same Client.
type LoginSession struct {
mu sync.Mutex
restClient *rest.Client
username string
password string
hardwareID string
done bool
}
type LoginSessionRequest struct {
Username string
Password string
HardwareID string
}
type CompleteLoginRequest struct{ OTPCode string }
func (c *Client) NewLoginSession(req LoginSessionRequest) (*LoginSession, error) {
if req.Username == "" || req.Password == "" {
return nil, ringapimodels.NewBadRequestError("username and password are required", nil)
}
hardwareID := req.HardwareID
if hardwareID == "" {
hardwareID = uuid.NewString()
}
// OAuth flow cookies belong to the login, not to the shared HTTP client.
httpClient := *c.restClient.HTTPClient()
httpClient.Jar = nil
return &LoginSession{
restClient: rest.NewClient(
rest.WithHTTPClient(&httpClient),
rest.WithEndpointBases(c.endpoints.APIBaseURL, c.endpoints.OAuthBaseURL),
rest.WithUserAgent(c.userAgent),
rest.WithHardwareID(hardwareID),
),
username: req.Username, password: req.Password, hardwareID: hardwareID,
}, nil
}
func (s *LoginSession) HardwareID() string { return s.hardwareID }
func (s *LoginSession) Request2FACode(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.done {
return ringapimodels.NewClosedError("login session is closed")
}
return s.restClient.Request2FACode(ctx, s.username, s.password, s.hardwareID)
}
func (s *LoginSession) Authenticate(
ctx context.Context,
req CompleteLoginRequest,
) (*ringapimodels.AuthResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.done {
return nil, ringapimodels.NewClosedError("login session is closed")
}
tokens, err := s.restClient.Authenticate(ctx, s.username, s.password, s.hardwareID, req.OTPCode)
if err != nil {
return nil, err
}
s.done = true
s.password = ""
return &ringapimodels.AuthResponse{
AccessToken: tokens.AccessToken, RefreshToken: tokenString(tokens.RefreshToken),
ExpiresIn: tokenInt(tokens.ExpiresIn), TokenType: tokens.TokenType,
}, nil
}
func (s *LoginSession) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.done = true
s.password = ""
s.restClient = nil
return nil
}
package ring
import (
"encoding/json"
"fmt"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
// String returns the wire name of a known SDP type.
func (t SDPType) String() string {
switch t {
case SDPTypeOffer:
return protocol.SDPTypeOffer
case SDPTypeAnswer:
return protocol.SDPTypeAnswer
case SDPTypeUnknown:
return ""
default:
return ""
}
}
func (t SDPType) MarshalJSON() ([]byte, error) {
if t.String() == "" {
return nil, ringapimodels.NewBadRequestError(fmt.Sprintf("invalid SDP type %d", t), nil)
}
encoded, err := json.Marshal(t.String())
if err != nil {
return nil, ringapimodels.NewInternalServerError("failed to encode SDP type", err)
}
return encoded, nil
}
func (t *SDPType) UnmarshalJSON(data []byte) error {
var value string
err := json.Unmarshal(data, &value)
if err != nil {
return ringapimodels.NewBadRequestError("invalid SDP type encoding", err)
}
switch value {
case protocol.SDPTypeOffer:
*t = SDPTypeOffer
case protocol.SDPTypeAnswer:
*t = SDPTypeAnswer
default:
return ringapimodels.NewBadRequestError(fmt.Sprintf("invalid SDP type %q", value), nil)
}
return nil
}
package ring
import (
"context"
"github.com/portpowered/go-ring/internal/signaling"
dependencywebsocket "github.com/portpowered/go-ring/pkg/dependencies/websocket"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
func (c *SignalingConnection) send(ctx context.Context, message signaling.Message) error {
err := ctx.Err()
if err != nil {
return sessionError("signaling send canceled", err)
}
select {
case <-c.done:
return c.Err()
default:
}
return sessionError("signaling send failed", c.writer.Send(ctx, message))
}
func (c *SignalingConnection) writeFrame(ctx context.Context, m signaling.Message) error {
return dependencywebsocket.WriteSignaling(ctx, c.conn, m)
}
func (c *SignalingConnection) readLoop() {
defer close(c.readerDone)
err := dependencywebsocket.ReadSignaling(c.conn, c.route)
if err != nil && !c.isClosed() {
c.fail(sessionError("signaling read failed", err))
}
}
func (c *SignalingConnection) route(message signaling.Message) {
c.mu.Lock()
pending := c.pending[message.DialogID]
session := c.sessions[message.DialogID]
channel := c.channels[message.DialogID]
playback := c.playbacks[message.DialogID]
push := c.pushes[message.DialogID]
c.mu.Unlock()
if pending != nil {
select {
case pending <- message:
default:
c.fail(ringapimodels.NewConnectionError("signaling negotiation queue full", nil))
}
return
}
if session != nil {
session.handle(message)
return
}
if playback != nil {
playback.handle(message)
return
}
if channel != nil {
select {
case channel <- message:
default:
if push != nil {
push.terminate(ringapimodels.NewConnectionError("push event queue full", signaling.ErrBackpressure))
} else {
// An unowned channel is still negotiating. Its waiter cannot
// recover from a dropped protocol message.
c.fail(ringapimodels.NewConnectionError("signaling negotiation queue full", signaling.ErrBackpressure))
}
}
}
}
func (c *SignalingConnection) isClosed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.closed
}
func (c *SignalingConnection) fail(err error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
c.terminal = err
close(c.done)
sessions := make([]*DeviceSession, 0, len(c.sessions))
for _, s := range c.sessions {
sessions = append(sessions, s)
}
playbacks := make([]*PlaybackSession, 0, len(c.playbacks))
for _, s := range c.playbacks {
playbacks = append(playbacks, s)
}
pushes := make([]*PushSubscription, 0, len(c.pushes))
for _, s := range c.pushes {
pushes = append(pushes, s)
}
c.mu.Unlock()
c.cancel()
for _, s := range sessions {
s.terminate(err)
}
for _, s := range playbacks {
s.terminate(err)
}
for _, s := range pushes {
s.terminate(err)
}
_ = c.conn.Close()
}
func (c *SignalingConnection) Err() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.terminal != nil {
return sessionError("signaling connection ended", c.terminal)
}
return ringapimodels.NewClosedError("signaling connection is closed", signaling.ErrClosed)
}
func (c *SignalingConnection) removeSession(dialog string) {
c.mu.Lock()
delete(c.sessions, dialog)
delete(c.pending, dialog)
c.mu.Unlock()
}
func (c *SignalingConnection) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
<-c.readerDone
<-c.writer.Finished()
return nil
}
c.closed = true
children := make([]*DeviceSession, 0, len(c.sessions))
for _, s := range c.sessions {
children = append(children, s)
}
playbacks := make([]*PlaybackSession, 0, len(c.playbacks))
for _, s := range c.playbacks {
playbacks = append(playbacks, s)
}
pushes := make([]*PushSubscription, 0, len(c.pushes))
for _, s := range c.pushes {
pushes = append(pushes, s)
}
c.mu.Unlock()
closeCtx, closeCancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
defer closeCancel()
for _, s := range children {
s.closeWithContext(closeCtx, true)
}
c.mu.Lock()
c.terminal = signaling.ErrClosed
close(c.done)
c.mu.Unlock()
for _, s := range playbacks {
s.terminate(signaling.ErrClosed)
}
for _, s := range pushes {
s.terminate(signaling.ErrClosed)
}
c.cancel()
_ = c.conn.Close()
<-c.readerDone
<-c.writer.Finished()
return nil
}
package ring
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/google/uuid"
"github.com/portpowered/go-ring/internal/protocol"
"github.com/portpowered/go-ring/internal/signaling"
"github.com/portpowered/go-ring/pkg/dependencies/webrtc"
generatedsignaling "github.com/portpowered/go-ring/pkg/dependencymodels/signaling"
"github.com/portpowered/go-ring/pkg/ringapimodels"
)
const defaultPlaybackEntryPoint = protocol.PlaybackEntryPointTimeline
func (c *SignalingConnection) registerChannel() (string, chan signaling.Message, error) {
name := uuid.NewString()
ch := make(chan signaling.Message, signaling.NegotiationQueueCapacity)
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return "", nil, c.terminal
}
c.channels[name] = ch
return name, ch, nil
}
func (c *SignalingConnection) removeChannel(name string) {
c.mu.Lock()
delete(c.channels, name)
delete(c.pushes, name)
c.mu.Unlock()
}
func (c *SignalingConnection) sendTyped(ctx context.Context, method string, dialog, riid string, body any) error {
if riid == "" {
riid = uuid.NewString()
}
raw, err := json.Marshal(body)
if err != nil {
return ringapimodels.NewInternalServerError("failed to encode signaling message", err)
}
return c.send(ctx, signaling.Message{Method: method, DialogID: dialog, RIID: riid, Body: raw})
}
// SubscribePush registers captured shoulder-tap style notification filters.
// It keeps the subscription alive until Close or its parent connection ends.
func (c *SignalingConnection) SubscribePush(ctx context.Context, filters []PushFilter) (*PushSubscription, error) {
if len(filters) == 0 {
return nil, ringapimodels.NewBadRequestError("at least one push filter is required", nil)
}
dialog, events, err := c.registerChannel()
if err != nil {
return nil, err
}
wireFilters := make([]generatedsignaling.PushFilter, 0, len(filters))
for _, filter := range filters {
ids := make([]int, len(filter.Filters.DoorbotIDs))
for i, id := range filter.Filters.DoorbotIDs {
ids[i] = int(id)
}
wireFilters = append(
wireFilters,
generatedsignaling.PushFilter{
FilterIdentifier: filter.FilterIdentifier,
Filters: &generatedsignaling.PushFilters{
DoorbotIds: ids,
AdditionalProperties: nil,
},
NotificationScope: filter.NotificationScope,
NotificationType: filter.NotificationType,
AdditionalProperties: nil,
},
)
}
{
err = c.sendTyped(ctx, protocol.MethodPushSubscribe, dialog, "", generatedsignaling.PushSubscribeBody{
RequestedNotifications: wireFilters,
AdditionalProperties: nil,
})
if err != nil {
c.removeChannel(dialog)
return nil, err
}
}
for {
select {
case <-ctx.Done():
c.removeChannel(dialog)
return nil, ringapimodels.NewConnectionError("push subscription canceled", ctx.Err())
case <-c.done:
c.removeChannel(dialog)
return nil, c.Err()
case message := <-events:
if message.Method != protocol.MethodPushSubscriptionAck {
continue
}
var ack generatedsignaling.PushSubscriptionAckBody
if json.Unmarshal(message.Body, &ack) != nil || ack.Status != protocol.SubscriptionStatusOK ||
ack.SubscriptionId == "" {
c.removeChannel(dialog)
return nil, ringapimodels.NewConnectionError("push subscription rejected", nil)
}
subscription := &PushSubscription{
connection: c,
dialog: dialog,
id: ack.SubscriptionId,
events: events,
done: make(chan struct{}),
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, c.Err()
}
if c.pushes == nil {
c.pushes = make(map[string]*PushSubscription)
}
c.pushes[dialog] = subscription
c.mu.Unlock()
//nolint:contextcheck // The subscription belongs to the connection after the request is acknowledged.
go subscription.heartbeat(
c.ctx,
)
return subscription, nil
}
}
}
func (s *PushSubscription) heartbeat(ctx context.Context) {
const pushHeartbeatInterval = 30 * time.Second
s.heartbeatAt(ctx, pushHeartbeatInterval)
}
func (s *PushSubscription) heartbeatAt(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.done:
return
case <-s.connection.done:
return
case <-ticker.C:
sendCtx, cancel := context.WithTimeout(ctx, signaling.SendTimeout)
_ = s.connection.sendTyped(
sendCtx,
protocol.MethodPushHeartbeat,
s.dialog,
"",
generatedsignaling.PushSubscriptionBody{SubscriptionId: s.id, AdditionalProperties: nil},
)
cancel()
}
}
}
func (s *PushSubscription) Receive(ctx context.Context) (PushEvent, error) {
for {
select {
case <-s.done:
return PushEvent{}, sessionError("push subscription ended", s.terminal)
default:
}
select {
case <-ctx.Done():
return PushEvent{}, ringapimodels.NewConnectionError("push receive canceled", ctx.Err())
case <-s.done:
return PushEvent{}, sessionError("push subscription ended", s.terminal)
case <-s.connection.done:
return PushEvent{}, s.connection.Err()
case message := <-s.events:
if message.Method != protocol.MethodPushEvent {
continue
}
var event PushEvent
err := json.Unmarshal(message.Body, &event)
if err != nil {
return PushEvent{}, ringapimodels.NewConnectionError("invalid push event", err)
}
if event.SubscriptionID != s.id {
return PushEvent{}, ringapimodels.NewConnectionError("push subscription ID mismatch", nil)
}
return event, nil
}
}
}
func (s *PushSubscription) Close() error {
var err error
s.once.Do(func() {
s.terminal = signaling.ErrClosed
close(s.done)
s.connection.removeChannel(s.dialog)
ctx, cancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
defer cancel()
err = s.connection.sendTyped(
ctx,
protocol.MethodPushUnsubscribe,
s.dialog,
"",
generatedsignaling.PushSubscriptionBody{SubscriptionId: s.id, AdditionalProperties: nil},
)
})
return err
}
func (s *PushSubscription) terminate(err error) {
s.once.Do(func() {
s.terminal = err
close(s.done)
s.connection.removeChannel(s.dialog)
})
}
func (c *SignalingConnection) StartPlayback(ctx context.Context, req StartPlaybackRequest) (*PlaybackSession, error) {
id, err := strconv.ParseInt(req.DeviceID, 10, 64)
if err != nil || id <= 0 {
return nil, ringapimodels.NewBadRequestError("device ID must be a positive integer", err)
}
if req.Offer.Type != SDPTypeOffer || req.Offer.SDP == "" {
return nil, ringapimodels.NewBadRequestError("playback requires an SDP offer", nil)
}
{
_, err = webrtc.ParseSDP(req.Offer.SDP)
if err != nil {
return nil, ringapimodels.NewBadRequestError("invalid SDP offer", err)
}
}
entry := req.EntryPoint
if entry == "" {
entry = defaultPlaybackEntryPoint
}
dialog, events, err := c.registerChannel()
if err != nil {
return nil, err
}
{
err = c.sendTyped(ctx, protocol.MethodPlayback, dialog, "", generatedsignaling.PlaybackOfferBody{
DoorbotId: int(id),
EntryPoint: entry,
Sdp: req.Offer.SDP,
ReservedType: protocol.PlaybackOfferTypeCloud,
AdditionalProperties: nil,
})
if err != nil {
c.removeChannel(dialog)
return nil, err
}
}
deadline, cancel := context.WithTimeout(ctx, signaling.NegotiationTimeout)
defer cancel()
for {
select {
case <-deadline.Done():
c.removeChannel(dialog)
return nil, ringapimodels.NewConnectionError(
"playback negotiation timed out or was canceled",
deadline.Err(),
)
case <-c.done:
c.removeChannel(dialog)
return nil, c.Err()
case message := <-events:
if message.Method != protocol.MethodSDP {
continue
}
var body generatedsignaling.PlaybackAnswerBody
if json.Unmarshal(message.Body, &body) != nil || int64(body.DoorbotId) != id || body.SessionId == "" ||
body.ReservedType != protocol.SDPTypeAnswer ||
body.Sdp == "" {
c.removeChannel(dialog)
return nil, ringapimodels.NewConnectionError("invalid playback SDP answer", nil)
}
session := &PlaybackSession{
connection: c,
dialog: dialog,
riid: message.RIID,
id: body.SessionId,
deviceID: id,
answer: SessionDescription{Type: SDPTypeAnswer, SDP: body.Sdp},
events: events,
done: make(chan struct{}),
}
session.lastPong.Store(time.Now().UnixNano())
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, c.Err()
}
c.playbacks[dialog] = session
c.mu.Unlock()
interval := signaling.DefaultHeartbeatInterval
if body.SessionInfo != nil {
interval = time.Duration(body.SessionInfo.PingInterval) * time.Second
}
if interval <= 0 || interval > signaling.MaxHeartbeatInterval {
interval = signaling.DefaultHeartbeatInterval
}
//nolint:contextcheck // Playback lifetime is owned by the signaling connection.
go session.keepalive(
c.ctx,
interval,
)
return session, nil
}
}
}
func (s *PlaybackSession) Answer() SessionDescription { return s.answer }
func (s *PlaybackSession) handle(message signaling.Message) {
if message.Method == protocol.MethodPong {
var body generatedsignaling.SessionBody
if json.Unmarshal(message.Body, &body) == nil && int64(body.DoorbotId) == s.deviceID && body.SessionId == s.id {
s.lastPong.Store(time.Now().UnixNano())
}
return
}
if message.Method == protocol.MethodClose {
s.terminate(signaling.ErrClosed)
return
}
select {
case s.events <- message:
default:
s.terminate(signaling.ErrBackpressure)
}
}
func (s *PlaybackSession) terminate(err error) {
s.once.Do(func() {
s.terminal = err
close(s.done)
s.connection.mu.Lock()
delete(s.connection.playbacks, s.dialog)
delete(s.connection.channels, s.dialog)
s.connection.mu.Unlock()
})
}
func (s *PlaybackSession) keepalive(ctx context.Context, interval time.Duration) {
s.keepaliveFor(ctx, interval, signaling.MaxSessionAge)
}
func (s *PlaybackSession) keepaliveFor(ctx context.Context, interval, lifetime time.Duration) {
ping := time.NewTicker(interval)
defer ping.Stop()
expiry := time.NewTimer(lifetime)
defer expiry.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.done:
return
case <-s.connection.done:
return
case <-expiry.C:
closeCtx, cancel := context.WithTimeout(ctx, signaling.CloseTimeout)
_ = s.closeWithContext(closeCtx)
cancel()
return
case <-ping.C:
if time.Since(time.Unix(0, s.lastPong.Load())) > 3*interval {
s.terminate(signaling.ErrHeartbeat)
return
}
sendCtx, cancel := context.WithTimeout(ctx, signaling.SendTimeout)
_ = s.connection.sendTyped(
sendCtx,
protocol.MethodPing,
s.dialog,
s.riid,
generatedsignaling.SessionBody{
DoorbotId: int(s.deviceID),
SessionId: s.id,
AdditionalProperties: nil,
},
)
cancel()
}
}
}
func (s *PlaybackSession) SendICE(ctx context.Context, candidate ICECandidateRequest) error {
if candidate.Candidate == "" || candidate.MLineIndex < 0 {
return ringapimodels.NewBadRequestError("invalid playback ICE candidate", nil)
}
return s.connection.sendTyped(
ctx,
protocol.MethodICE,
s.dialog,
s.riid,
generatedsignaling.IceCandidateBody{
DoorbotId: int(s.deviceID),
SessionId: s.id,
Ice: candidate.Candidate,
Mlineindex: candidate.MLineIndex,
AdditionalProperties: nil,
},
)
}
func (s *PlaybackSession) Receive(ctx context.Context) (SessionEvent, error) {
select {
case <-ctx.Done():
return SessionEvent{}, ringapimodels.NewConnectionError("playback receive canceled", ctx.Err())
case <-s.done:
return SessionEvent{}, sessionError("playback session ended", s.terminal)
case <-s.connection.done:
return SessionEvent{}, s.connection.Err()
case m := <-s.events:
return SessionEvent{Method: m.Method, Body: m.Body}, nil
}
}
func (s *PlaybackSession) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), signaling.CloseTimeout)
defer cancel()
return s.closeWithContext(ctx)
}
func (s *PlaybackSession) closeWithContext(ctx context.Context) error {
var err error
s.once.Do(func() {
s.terminal = signaling.ErrClosed
close(s.done)
s.connection.mu.Lock()
delete(s.connection.playbacks, s.dialog)
delete(s.connection.channels, s.dialog)
s.connection.mu.Unlock()
err = s.connection.sendTyped(
ctx,
protocol.MethodClose,
s.dialog,
s.riid,
generatedsignaling.PlaybackCloseBody{
DoorbotId: int(s.deviceID),
SessionId: s.id,
Reason: &generatedsignaling.PlaybackCloseReason{
Code: 0,
Text: protocol.PlaybackCloseReasonClientClosed,
AdditionalProperties: nil,
},
AdditionalProperties: nil,
},
)
})
return err
}
package ringapimodels
import (
"math"
"github.com/portpowered/go-ring/pkg/ringtypes"
)
// Status derives a concise health view without implying a battery on wired
// devices.
func (device DeviceDetailDevice) Status() DeviceStatus {
status := DeviceStatus{
BatteryPercent: nil,
Connection: nil,
IsOffline: false,
PowerMode: nil,
}
if device.Settings != nil {
status.PowerMode = device.Settings.PowerMode
}
if device.Alerts != nil {
status.Connection = device.Alerts.Connection
status.IsOffline = device.Alerts.Connection != nil &&
*device.Alerts.Connection == ringtypes.ConnectionState("offline")
}
if status.Connection == nil && device.Health != nil && device.Health.Connected != nil {
state := ringtypes.ConnectionState("offline")
if *device.Health.Connected {
state = ringtypes.ConnectionState("online")
}
status.Connection = &state
status.IsOffline = state == ringtypes.ConnectionState("offline")
}
if device.Health != nil && device.Health.BatteryPresent != nil && !*device.Health.BatteryPresent {
return status
}
if status.PowerMode != nil && *status.PowerMode == ringtypes.PowerMode("wired") &&
(device.Health == nil || device.Health.BatteryPresent == nil || !*device.Health.BatteryPresent) {
return status
}
if device.Health != nil {
switch {
case device.Health.BatteryPercentage != nil:
status.BatteryPercent = validBattery(*device.Health.BatteryPercentage)
case device.Health.BatteryLevel != nil:
status.BatteryPercent = validBattery(float64(*device.Health.BatteryLevel))
}
}
if status.BatteryPercent == nil && (status.PowerMode == nil || *status.PowerMode != ringtypes.PowerMode("wired")) {
if device.BatteryLife != nil {
status.BatteryPercent = validBattery(float64(*device.BatteryLife))
}
}
return status
}
func validBattery(value float64) *float64 {
if math.IsNaN(value) || value < 0 || value > 100 {
return nil
}
return &value
}
package ringapimodels
// Supports reports whether inventory explicitly confirms a device operation.
// False also covers unknown support, so callers must not treat it as proof
// that the server rejects an operation.
func (d Device) Supports(capability DeviceCapability) bool {
for _, supported := range d.Capabilities {
if supported == capability {
return true
}
}
return false
}
package ringapimodels
import (
"net/http"
"github.com/portpowered/go-ring/internal/ringerrors"
)
// Public error aliases preserve errors.As and existing SDK callers while the
// transport packages use the shared internal definitions directly.
type AuthenticationError = ringerrors.AuthenticationError
type ConnectionError = ringerrors.ConnectionError
type NetworkError = ringerrors.NetworkError
type TokenError = ringerrors.TokenError
type BadRequestError = ringerrors.BadRequestError
type UnauthorizedError = ringerrors.UnauthorizedError
type NotFoundError = ringerrors.NotFoundError
type InternalServerError = ringerrors.InternalServerError
type HTTPError = ringerrors.HTTPError
type ClosedError = ringerrors.ClosedError
type Requires2FAError = ringerrors.Requires2FAError
type RateLimitError = ringerrors.RateLimitError
func NewRequires2FAError(message string) *Requires2FAError {
return ringerrors.NewRequires2FAError(message)
}
func NewRateLimitError(message string) *RateLimitError { return ringerrors.NewRateLimitError(message) }
func NewAuthenticationError(message string, status int) *AuthenticationError {
return ringerrors.NewAuthenticationError(message, status)
}
func NewConnectionError(message string, err error) *ConnectionError {
return ringerrors.NewConnectionError(message, err)
}
func NewNetworkError(message string, err error) *NetworkError {
return ringerrors.NewNetworkError(message, err)
}
func NewTokenError(message string, err error) *TokenError {
return ringerrors.NewTokenError(message, err)
}
func NewHTTPError(resp *http.Response, body string) *HTTPError {
return ringerrors.NewHTTPError(resp, body)
}
func ClassifyHTTPError(resp *http.Response, body string) error {
return ringerrors.ClassifyHTTPError(resp, body)
}
func NewClosedError(message string, cause ...error) *ClosedError {
return ringerrors.NewClosedError(message, cause...)
}
func NewBadRequestError(message string, err error) *BadRequestError {
return ringerrors.NewBadRequestError(message, err)
}
func NewNotFoundError(message string, err error) *NotFoundError {
return ringerrors.NewNotFoundError(message, err)
}
func NewUnauthorizedError(message string, err error) *UnauthorizedError {
return ringerrors.NewUnauthorizedError(message, err)
}
func NewInternalServerError(message string, err error) *InternalServerError {
return ringerrors.NewInternalServerError(message, err)
}
func IsAuthenticationError(err error) bool { return ringerrors.IsAuthenticationError(err) }
func IsConnectionError(err error) bool { return ringerrors.IsConnectionError(err) }
func IsNetworkError(err error) bool { return ringerrors.IsNetworkError(err) }
func IsTokenError(err error) bool { return ringerrors.IsTokenError(err) }
func IsBadRequestError(err error) bool { return ringerrors.IsBadRequestError(err) }
func IsUnauthorizedError(err error) bool { return ringerrors.IsUnauthorizedError(err) }
func IsNotFoundError(err error) bool { return ringerrors.IsNotFoundError(err) }
func IsInternalServerError(err error) bool { return ringerrors.IsInternalServerError(err) }
func IsHTTPError(err error) bool { return ringerrors.IsHTTPError(err) }
func IsHTTPStatusCode(err error, code int) bool {
return ringerrors.IsHTTPStatusCode(err, code)
}
func IsClosedError(err error) bool { return ringerrors.IsClosedError(err) }
func IsRequires2FAError(err error) bool { return ringerrors.IsRequires2FAError(err) }
func IsRateLimitError(err error) bool { return ringerrors.IsRateLimitError(err) }
// Package ringtypes contains schema-backed scalar types shared by the public
// API projections and generated provider models.
package ringtypes
import (
"encoding/json"
"strconv"
)
type scalarDecodeError struct {
name string
cause error
}
func (failure scalarDecodeError) Error() string {
return failure.name + ": " + failure.cause.Error()
}
func (failure scalarDecodeError) Unwrap() error { return failure.cause }
func wrapScalarDecodeError(name string, cause error) error {
if cause == nil {
return nil
}
return scalarDecodeError{name: name, cause: cause}
}
// BatteryReading accepts Ring's numeric and numeric-text percentage values.
type BatteryReading float64
func (reading *BatteryReading) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var value json.Number
{
err := json.Unmarshal(data, &value)
if err != nil {
var text string
err = json.Unmarshal(data, &text)
if err != nil {
return wrapScalarDecodeError("battery reading", err)
}
value = json.Number(text)
}
}
percentage, err := strconv.ParseFloat(value.String(), 64)
if err != nil {
return wrapScalarDecodeError("battery reading", err)
}
*reading = BatteryReading(percentage)
return nil
}
// OwnerID normalizes a numeric or textual provider identifier to a string.
type OwnerID string
func (id *OwnerID) UnmarshalJSON(data []byte) error {
var text string
if len(data) > 0 && data[0] == '"' {
err := json.Unmarshal(data, &text)
if err != nil {
return wrapScalarDecodeError("owner identifier", err)
}
*id = OwnerID(text)
return nil
}
var number int64
err := json.Unmarshal(data, &number)
if err != nil {
return wrapScalarDecodeError("owner identifier", err)
}
*id = OwnerID(strconv.FormatInt(number, 10))
return nil
}
// DeviceKind is an open provider hardware kind.
type DeviceKind string
// DeviceFamily is an open provider family label.
type DeviceFamily string
// ConnectionState is an open provider connectivity state.
type ConnectionState string
// PowerMode is an open provider power configuration.
type PowerMode string
// LocationResourceType is an open JSON:API resource type.
type LocationResourceType string
// TimelineEventType is an open provider event kind.
type TimelineEventType string
// RecordingStatus is an open provider recording status.
type RecordingStatus string
// TimelineState is an open provider timeline state.
type TimelineState string
// HistoryFeedType is an open provider history feed classification.
type HistoryFeedType string