// Package mapdata decodes bounded inner map payloads without network or rendering.
package mapdata
import (
"errors"
"fmt"
"github.com/portpowered/go-roborock/internal/mapmodel"
)
const (
maxPayload = 32 * 1024 * 1024
maxCells = 16_000_000
)
// ErrMalformed identifies unsupported, truncated, or inconsistent map data.
var ErrMalformed = errors.New("malformed map data")
// Decode parses decrypted, inflated V1/Q7 bytes or an inner Q10 packet.
func Decode(format mapmodel.MapFormat, payload []byte) (*mapmodel.MapSnapshot, error) {
if len(payload) == 0 || len(payload) > maxPayload {
return nil, fmt.Errorf("payload size %d: %w", len(payload), ErrMalformed)
}
snapshot := &mapmodel.MapSnapshot{
Format: format,
Paths: []mapmodel.MapPath{},
Rooms: []mapmodel.MapRoom{},
CurrentZones: []mapmodel.MapArea{},
Restrictions: []mapmodel.MapArea{},
Walls: []mapmodel.MapArea{},
UnknownBlocks: []mapmodel.MapUnknownBlock{},
Dock: nil,
FormatId: nil,
Grid: nil,
MapId: nil,
Robot: nil,
}
var err error
switch format {
case mapmodel.MapFormatV1:
err = decodeV1(payload, snapshot)
case mapmodel.MapFormatQ7:
err = decodeQ7(payload, snapshot)
case mapmodel.MapFormatQ10:
err = decodeQ10(payload, snapshot)
default:
err = fmt.Errorf("format %q: %w", format, ErrMalformed)
}
if err != nil {
return nil, err
}
return snapshot, nil
}
func pointer[T any](value T) *T { return &value }
func cells(width, height int) (int, error) {
if width <= 0 || height <= 0 || width > maxCells/height {
return 0, fmt.Errorf("grid dimensions %dx%d: %w", width, height, ErrMalformed)
}
return width * height, nil
}
package mapdata
import (
"fmt"
"math"
"github.com/portpowered/go-roborock/internal/mapmodel"
)
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func calibrated(grid mapmodel.MapGrid) error {
if grid.Origin == nil || grid.Resolution == nil || !finite(*grid.Resolution) || *grid.Resolution <= 0 ||
!finite(grid.Origin.X) || !finite(grid.Origin.Y) || (grid.RowYDirection != 1 && grid.RowYDirection != -1) {
return fmt.Errorf("grid calibration unavailable: %w", ErrMalformed)
}
_, err := cells(grid.Width, grid.Height)
return err
}
// MapToPixel converts source-frame world coordinates to displayed top-down pixels.
func MapToPixel(grid mapmodel.MapGrid, point mapmodel.MapPoint) (mapmodel.MapPoint, error) {
err := calibrated(grid)
if err != nil {
return mapmodel.MapPoint{}, err
}
if !finite(point.X) || !finite(point.Y) {
return mapmodel.MapPoint{}, fmt.Errorf("non-finite point: %w", ErrMalformed)
}
result := mapmodel.MapPoint{
X: (point.X - grid.Origin.X) / *grid.Resolution,
Y: (point.Y - grid.Origin.Y) / *grid.Resolution / float64(grid.RowYDirection),
}
if !grid.TopDown {
result.Y = float64(grid.Height-1) - result.Y
}
if !finite(result.X) || !finite(result.Y) {
return mapmodel.MapPoint{}, fmt.Errorf("point overflow: %w", ErrMalformed)
}
return result, nil
}
// PixelToMap reverses MapToPixel without rounding or changing firmware frames.
func PixelToMap(grid mapmodel.MapGrid, point mapmodel.MapPoint) (mapmodel.MapPoint, error) {
err := calibrated(grid)
if err != nil {
return mapmodel.MapPoint{}, err
}
if !finite(point.X) || !finite(point.Y) {
return mapmodel.MapPoint{}, fmt.Errorf("non-finite point: %w", ErrMalformed)
}
rowY := point.Y
if !grid.TopDown {
rowY = float64(grid.Height-1) - rowY
}
result := mapmodel.MapPoint{
X: grid.Origin.X + point.X**grid.Resolution,
Y: grid.Origin.Y + rowY**grid.Resolution*float64(grid.RowYDirection),
}
if !finite(result.X) || !finite(result.Y) {
return mapmodel.MapPoint{}, fmt.Errorf("point overflow: %w", ErrMalformed)
}
return result, nil
}
// PixelRectangleToMap validates a displayed raster rectangle and converts its corners.
func PixelRectangleToMap(grid mapmodel.MapGrid, rectangle mapmodel.MapRectangle) (mapmodel.MapRectangle, error) {
if rectangle.Min.X < 0 || rectangle.Min.Y < 0 || rectangle.Max.X > float64(grid.Width-1) ||
rectangle.Max.Y > float64(grid.Height-1) || rectangle.Min.X >= rectangle.Max.X || rectangle.Min.Y >= rectangle.Max.Y {
return mapmodel.MapRectangle{}, fmt.Errorf("rectangle outside grid or degenerate: %w", ErrMalformed)
}
minimum, err := PixelToMap(grid, rectangle.Min)
if err != nil {
return mapmodel.MapRectangle{}, err
}
maximum, err := PixelToMap(grid, rectangle.Max)
if err != nil {
return mapmodel.MapRectangle{}, err
}
return mapmodel.MapRectangle{
Min: mapmodel.MapPoint{
X: math.Min(minimum.X,
maximum.X),
Y: math.Min(minimum.Y,
maximum.Y),
},
Max: mapmodel.MapPoint{
X: math.Max(minimum.X,
maximum.X),
Y: math.Max(minimum.Y,
maximum.Y),
},
}, nil
}
package mapdata
import (
"encoding/binary"
"fmt"
"github.com/portpowered/go-roborock/internal/protocol"
)
func lz4Length(data []byte, index *int, value, limit int) (int, error) {
if value != protocol.MapLZ4NibbleExtension {
return value, nil
}
for {
if *index >= len(data) {
return 0, fmt.Errorf("LZ4 truncated length: %w", ErrMalformed)
}
part := int(data[*index])
*index++
if value > limit-part {
return 0, fmt.Errorf("LZ4 expansion bound: %w", ErrMalformed)
}
value += part
if part != protocol.MapLZ4LengthExtension {
return value, nil
}
}
}
func decompressLZ4(data []byte, limit int) ([]byte, error) {
output := make([]byte, 0)
index := 0
for index < len(data) {
token := data[index]
index++
length, err := lz4Length(data, &index, int(token>>protocol.MapLZ4TokenShift), limit)
if err != nil {
return nil, err
}
if length > len(data)-index || length > limit-len(output) {
return nil, fmt.Errorf("LZ4 literal bounds: %w", ErrMalformed)
}
output = append(output, data[index:index+length]...)
index += length
if index == len(data) {
return output, nil
}
output, index, err = lz4Match(data, output, index, limit, token)
if err != nil {
return nil, err
}
}
return nil, fmt.Errorf("LZ4 missing final token: %w", ErrMalformed)
}
func lz4Match(data, output []byte, index, limit int, token byte) ([]byte, int, error) {
if len(data)-index < protocol.MapLZ4OffsetSize {
return nil, index, fmt.Errorf("LZ4 truncated offset: %w", ErrMalformed)
}
offset := int(binary.LittleEndian.Uint16(data[index:]))
index += protocol.MapLZ4OffsetSize
if offset == 0 || offset > len(output) {
return nil, index, fmt.Errorf("LZ4 invalid back-reference: %w", ErrMalformed)
}
length, err := lz4Length(data, &index, int(token&protocol.MapLZ4NibbleExtension), limit)
if err != nil {
return nil, index, err
}
if length > limit-len(output)-protocol.MapLZ4MatchMinimum {
return nil, index, fmt.Errorf("LZ4 match bounds: %w", ErrMalformed)
}
for range length + protocol.MapLZ4MatchMinimum {
output = append(output, output[len(output)-offset])
}
return output, index, nil
}
package mapdata
import (
"fmt"
"google.golang.org/protobuf/encoding/protowire"
"google.golang.org/protobuf/reflect/protoreflect"
)
const (
maxProtoFields = 500_000
maxProtoMessages = 65_536
maxProtoDepth = 32
maxProtoUnmarshalDepth = 64
)
type protoBudget struct {
fields int
messages int
}
func (budget *protoBudget) inspect(data []byte, descriptor protoreflect.MessageDescriptor, depth int) error {
if depth > maxProtoDepth || budget.messages >= maxProtoMessages {
return fmt.Errorf("protobuf message bound: %w", ErrMalformed)
}
budget.messages++
for len(data) > 0 {
budget.fields++
if budget.fields > maxProtoFields {
return fmt.Errorf("protobuf field bound: %w", ErrMalformed)
}
number, wireType, tagLength := protowire.ConsumeTag(data)
if tagLength < 0 {
return fmt.Errorf("protobuf tag: %w", ErrMalformed)
}
data = data[tagLength:]
length := protowire.ConsumeFieldValue(number, wireType, data)
if length < 0 {
return fmt.Errorf("protobuf field length: %w", ErrMalformed)
}
err := budget.inspectMessage(data, descriptor, number, wireType, depth)
if err != nil {
return err
}
data = data[length:]
}
return nil
}
func (budget *protoBudget) inspectMessage(
data []byte, descriptor protoreflect.MessageDescriptor,
number protowire.Number, wireType protowire.Type, depth int,
) error {
field := descriptor.Fields().ByNumber(number)
if field != nil && field.Kind() == protoreflect.MessageKind && wireType == protowire.BytesType {
inner, _ := protowire.ConsumeBytes(data)
err := budget.inspect(inner, field.Message(), depth+1)
if err != nil {
return err
}
}
return nil
}
package mapdata
import (
"encoding/binary"
"fmt"
"strings"
"github.com/portpowered/go-roborock/internal/mapmodel"
"github.com/portpowered/go-roborock/internal/protocol"
)
const (
signed16Boundary = 1 << 15
signed16Modulus = 1 << 16
)
func signedBE(data []byte, offset int) float64 {
value := int(binary.BigEndian.Uint16(data[offset : offset+protocol.MapQ10SignedCoordinateSize]))
if value >= signed16Boundary {
value -= signed16Modulus
}
return float64(value)
}
func decodeQ10(payload []byte, snapshot *mapmodel.MapSnapshot) error {
err := q10Marker(payload)
if err != nil {
return err
}
snapshot.FormatId = pointer(int64(payload[0]))
if payload[0] == protocol.MapQ10KindTrace {
return q10Trace(payload, snapshot)
}
if len(payload) < protocol.MapQ10HeaderLength {
return fmt.Errorf("Q10 header truncated: %w", ErrMalformed)
}
width := int(binary.BigEndian.Uint16(payload[protocol.MapQ10WidthOffset:]))
height := int(binary.BigEndian.Uint16(payload[protocol.MapQ10HeightOffset:]))
err = q10DeclaredDimensions(width, height)
if err != nil {
return err
}
length := int(binary.BigEndian.Uint16(payload[protocol.MapQ10CompressedLengthOffset:]))
if length == 0 || length > len(payload)-protocol.MapQ10HeaderLength {
return fmt.Errorf("Q10 layout length: %w", ErrMalformed)
}
end := protocol.MapQ10HeaderLength + length
limit := maxCells + protocol.MapQ10RoomHeaderLength + protocol.MapQ10MaximumRooms*protocol.MapQ10RoomRecordLength
decoded, err := decompressLZ4(payload[protocol.MapQ10HeaderLength:end], limit)
if err != nil {
return err
}
count, height, err := q10LayoutDimensions(decoded, width, height)
if err != nil {
return err
}
q10Grid(payload, decoded[:count], width, height, snapshot)
err = q10Rooms(decoded[count+protocol.MapQ10RoomHeaderLength:], snapshot)
if err != nil {
return err
}
return q10Tail(payload[end:], end, snapshot)
}
func q10Grid(payload, data []byte, width, height int, snapshot *mapmodel.MapSnapshot) {
grid := &mapmodel.MapGrid{
Width: width,
Height: height,
Cells: append([]byte(nil),
data...),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameQ10World,
TopDown: true,
RowYDirection: -1,
Origin: nil,
Resolution: nil,
RoomCells: nil,
}
originX, originY := signedBE(payload, protocol.MapQ10OriginXOffset), signedBE(payload, protocol.MapQ10OriginYOffset)
resolution := float64(binary.BigEndian.Uint16(payload[protocol.MapQ10ResolutionOffset:])) *
protocol.MapQ10ResolutionUnitMillimeters
if originX != 0 || originY != 0 {
grid.Origin = &mapmodel.MapPoint{
X: -originX / protocol.MapQ10HeaderUnitsPerPixel * resolution,
Y: originY / protocol.MapQ10HeaderUnitsPerPixel * resolution,
}
}
if resolution > 0 {
grid.Resolution = pointer(resolution)
}
snapshot.Grid = grid
snapshot.MapId = pointer(int64(binary.BigEndian.Uint32(payload[protocol.MapQ10IDOffset:])))
dockX, dockY := signedBE(payload, protocol.MapQ10DockXOffset), signedBE(payload, protocol.MapQ10DockYOffset)
if (dockX != 0 || dockY != 0) && dockX != -1 && dockY != -1 {
snapshot.Dock = &mapmodel.MapPose{
Point: mapmodel.MapPoint{
X: dockX / protocol.MapQ10HeaderUnitsPerPixel,
Y: dockY / protocol.MapQ10HeaderUnitsPerPixel,
},
Unit: mapmodel.MapUnitPixels,
Frame: mapmodel.MapFrameQ10Array,
Heading: pointer(signedBE(payload,
protocol.MapQ10DockHeadingOffset)),
HeadingUnit: nil,
}
}
}
func q10Rooms(decoded []byte, snapshot *mapmodel.MapSnapshot) error {
for start := 0; start < len(decoded); start += protocol.MapQ10RoomRecordLength {
record := decoded[start : start+protocol.MapQ10RoomRecordLength]
nameLength := int(record[protocol.MapQ10RoomNameLengthOffset])
if nameLength > protocol.MapQ10RoomRecordLength-protocol.MapQ10RoomNameOffset {
return fmt.Errorf("Q10 room name length: %w", ErrMalformed)
}
snapshot.Rooms = append(snapshot.Rooms, mapmodel.MapRoom{
Id: pointer(int64(binary.BigEndian.Uint16(record[:protocol.MapQ10RoomIDLength]))),
Name: pointer(strings.ToValidUTF8(
string(record[protocol.MapQ10RoomNameOffset:protocol.MapQ10RoomNameOffset+nameLength]),
"�")),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameQ10World,
Bounds: nil,
CleanState: nil,
Label: nil,
Material: nil,
Outline: nil,
RoomType: nil,
})
}
return nil
}
func q10Trace(payload []byte, snapshot *mapmodel.MapSnapshot) error {
if len(payload) < protocol.MapQ10TraceHeaderLength {
return fmt.Errorf("Q10 trace header: %w", ErrMalformed)
}
count := int(binary.BigEndian.Uint16(payload[protocol.MapQ10TracePointCountOffset:]))
if len(payload)-protocol.MapQ10TraceHeaderLength != count*protocol.MapQ10PointStride {
return fmt.Errorf("Q10 trace count: %w", ErrMalformed)
}
path := mapmodel.MapPath{
Kind: mapmodel.MapPathKindTrace,
Points: make([]mapmodel.MapPoint,
0,
count),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameQ10Trace,
}
for start := protocol.MapQ10TraceHeaderLength; start < len(payload); start += protocol.MapQ10PointStride {
path.Points = append(path.Points, mapmodel.MapPoint{
X: signedBE(payload,
start) * protocol.MapQ10TraceUnitMillimeters,
Y: signedBE(payload,
start+protocol.MapQ10SignedCoordinateSize) * protocol.MapQ10TraceUnitMillimeters,
})
}
snapshot.Paths = append(snapshot.Paths, path)
if count > 0 {
snapshot.Robot = &mapmodel.MapPose{
Point: path.Points[count-1],
Unit: path.Unit,
Frame: path.Frame,
Heading: pointer(signedBE(payload,
protocol.MapQ10TraceHeadingOffset)),
HeadingUnit: pointer(mapmodel.Degrees),
}
}
return nil
}
func q10Tail(tail []byte, offset int, snapshot *mapmodel.MapSnapshot) error {
if len(tail) == 0 {
return nil
}
if len(tail) < protocol.MapQ10RoomHeaderLength {
return fmt.Errorf("Q10 tail truncated: %w", ErrMalformed)
}
count, vertices := int(tail[0]), int(tail[1])
if count > 0 && (vertices == 0 || vertices > protocol.MapQ10EraseMaximumVertices) {
return fmt.Errorf("Q10 erase vertex count: %w", ErrMalformed)
}
end := protocol.MapQ10RoomHeaderLength + count*vertices*protocol.MapQ10PointStride
if end > len(tail) {
return fmt.Errorf("Q10 erase zone truncated: %w", ErrMalformed)
}
for start := protocol.MapQ10RoomHeaderLength; start < end; start += vertices * protocol.MapQ10PointStride {
area := mapmodel.MapArea{
Kind: mapmodel.MapAreaKindErase,
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameQ10World,
Points: make([]mapmodel.MapPoint,
0,
vertices),
SourceIndex: nil,
SourceStatus: nil,
SourceType: nil,
}
for i := range vertices {
position := start + i*protocol.MapQ10PointStride
area.Points = append(area.Points, mapmodel.MapPoint{
X: signedBE(tail,
position) * protocol.MapQ10WorldUnitMillimeters,
Y: signedBE(tail,
position+protocol.MapQ10SignedCoordinateSize) * protocol.MapQ10WorldUnitMillimeters,
})
}
snapshot.Restrictions = append(snapshot.Restrictions, area)
}
if end < len(tail) {
snapshot.UnknownBlocks = append(snapshot.UnknownBlocks, mapmodel.MapUnknownBlock{
Type: 0,
Offset: offset + end,
HeaderLength: 0,
DataLength: len(tail) - end,
})
}
return nil
}
func q10Marker(payload []byte) error {
if len(payload) < protocol.MapQ10SignedCoordinateSize || payload[1] != protocol.MapQ10MarkerRevision ||
payload[0] < protocol.MapQ10KindCurrent || payload[0] > protocol.MapQ10KindSaved {
return fmt.Errorf("Q10 marker: %w", ErrMalformed)
}
return nil
}
func q10RoomRecords(decoded []byte, count int) error {
if len(decoded) < count+protocol.MapQ10RoomHeaderLength ||
decoded[count] != protocol.MapQ10RoomHeaderMarker ||
int(decoded[count+protocol.MapQ10RoomCountOffset]) > protocol.MapQ10MaximumRooms ||
len(decoded) != count+protocol.MapQ10RoomHeaderLength+
int(decoded[count+protocol.MapQ10RoomCountOffset])*protocol.MapQ10RoomRecordLength {
return fmt.Errorf("Q10 room records: %w", ErrMalformed)
}
return nil
}
package mapdata
import (
"fmt"
"github.com/portpowered/go-roborock/internal/protocol"
)
func q10DeclaredDimensions(width, height int) error {
if width <= 0 {
return fmt.Errorf("Q10 width: %w", ErrMalformed)
}
if height == 0 {
return nil
}
_, err := cells(width, height)
return err
}
func q10LayoutDimensions(decoded []byte, width, height int) (int, int, error) {
if height > 0 {
count := width * height
err := q10RoomRecords(decoded, count)
if err == nil {
return count, height, nil
}
}
for roomCount := 0; roomCount <= protocol.MapQ10MaximumRooms; roomCount++ {
metadataLength := protocol.MapQ10RoomHeaderLength + roomCount*protocol.MapQ10RoomRecordLength
count := len(decoded) - metadataLength
if count <= 0 || count > maxCells || count%width != 0 {
continue
}
err := q10RoomRecords(decoded, count)
if err == nil {
return count, count / width, nil
}
}
return 0, 0, fmt.Errorf("Q10 dimensions cannot be inferred from exact room records: %w", ErrMalformed)
}
package mapdata
import (
"fmt"
"github.com/portpowered/go-roborock/internal/mapmodel"
"github.com/portpowered/go-roborock/pkg/dependencymodels/mapproto"
"google.golang.org/protobuf/proto"
)
func decodeQ7(payload []byte, snapshot *mapmodel.MapSnapshot) error {
wire := new(mapproto.RobotMap)
budget := new(protoBudget)
err := budget.inspect(payload, wire.ProtoReflect().Descriptor(), 0)
if err != nil {
return err
}
options := new(proto.UnmarshalOptions)
options.RecursionLimit = maxProtoUnmarshalDepth
err = options.Unmarshal(payload, wire)
if err != nil {
return fmt.Errorf("Q7 protobuf: %w: %w", ErrMalformed, err)
}
grid, err := q7Grid(wire)
if err != nil {
return err
}
snapshot.Grid = grid
q7Geometry(wire, snapshot)
q7Rooms(wire, snapshot)
return validatePoints(snapshot)
}
func q7Grid(wire *mapproto.RobotMap) (*mapmodel.MapGrid, error) {
head := wire.GetMapHead()
count, err := cells(int(head.GetSizeX()), int(head.GetSizeY()))
if err != nil {
return nil, err
}
data := wire.GetMapData().GetMapData()
if len(data) < count {
return nil, fmt.Errorf("Q7 grid truncated: %w", ErrMalformed)
}
grid := &mapmodel.MapGrid{
Width: int(head.GetSizeX()),
Height: int(head.GetSizeY()),
Cells: append([]byte(nil),
data[:count]...),
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
TopDown: false,
RowYDirection: 1,
Origin: nil,
Resolution: nil,
RoomCells: nil,
}
if head.MinX != nil && head.MinY != nil {
grid.Origin = &mapmodel.MapPoint{
X: float64(head.GetMinX()),
Y: float64(head.GetMinY()),
}
}
if head.Resolution != nil {
grid.Resolution = pointer(float64(head.GetResolution()))
}
err = validateQ7Calibration(grid)
if err != nil {
return nil, err
}
return grid, nil
}
func q7Geometry(wire *mapproto.RobotMap, snapshot *mapmodel.MapSnapshot) {
snapshot.FormatId = optionalUint(wire.MapType)
if wire.GetCurrentPose() != nil && wire.CurrentPose.X != nil && wire.CurrentPose.Y != nil {
snapshot.Robot = q7Pose(wire.GetCurrentPose().GetX(), wire.GetCurrentPose().GetY(), wire.GetCurrentPose().Phi)
}
if wire.GetChargeStation() != nil && wire.ChargeStation.X != nil && wire.ChargeStation.Y != nil {
snapshot.Dock = q7Pose(wire.GetChargeStation().GetX(), wire.GetChargeStation().GetY(), wire.GetChargeStation().Phi)
}
q7Paths(wire, snapshot)
q7Areas(wire, snapshot)
}
func q7Paths(wire *mapproto.RobotMap, snapshot *mapmodel.MapSnapshot) {
if wire.GetHistoryPose() != nil {
path := mapmodel.MapPath{
Kind: mapmodel.MapPathKindClean,
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
Points: []mapmodel.MapPoint{},
}
for _, point := range wire.GetHistoryPose().GetPoints() {
if point.X == nil || point.Y == nil {
continue
}
path.Points = append(path.Points, mapmodel.MapPoint{
X: float64(point.GetX()),
Y: float64(point.GetY()),
})
}
snapshot.Paths = append(snapshot.Paths, path)
}
}
func q7Areas(wire *mapproto.RobotMap, snapshot *mapmodel.MapSnapshot) {
for _, area := range wire.GetAreaInfo() {
item := mapmodel.MapArea{
Kind: mapmodel.MapAreaKindUnknown,
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
SourceType: optionalUint(area.Type),
SourceIndex: optionalUint(area.AreaIndex),
SourceStatus: optionalUint(area.Status),
Points: []mapmodel.MapPoint{},
}
for _, point := range area.GetPoints() {
if point.X == nil || point.Y == nil {
continue
}
item.Points = append(item.Points, mapmodel.MapPoint{
X: float64(point.GetX()),
Y: float64(point.GetY()),
})
}
snapshot.Restrictions = append(snapshot.Restrictions, item)
}
}
func optionalUint(value *uint32) *int64 {
if value == nil {
return nil
}
return pointer(int64(*value))
}
func q7Pose(x, y float32, heading *float32) *mapmodel.MapPose {
pose := &mapmodel.MapPose{
Point: mapmodel.MapPoint{
X: float64(x),
Y: float64(y),
},
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
Heading: nil,
HeadingUnit: nil,
}
if heading != nil {
pose.Heading = pointer(float64(*heading))
pose.HeadingUnit = pointer(mapmodel.Radians)
}
return pose
}
func q7Rooms(wire *mapproto.RobotMap, snapshot *mapmodel.MapSnapshot) {
for _, metadata := range wire.GetRoomDataInfo() {
room := mapmodel.MapRoom{
Id: optionalUint(metadata.RoomId),
Name: metadata.RoomName,
RoomType: optionalUint(metadata.RoomTypeId),
Material: optionalUint(metadata.MeterialId),
CleanState: optionalUint(metadata.CleanState),
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
Bounds: nil,
Label: nil,
Outline: nil,
}
if metadata.GetRoomNamePost() != nil && metadata.RoomNamePost.X != nil && metadata.RoomNamePost.Y != nil {
room.Label = &mapmodel.MapPoint{
X: float64(metadata.GetRoomNamePost().GetX()),
Y: float64(metadata.GetRoomNamePost().GetY()),
}
}
snapshot.Rooms = append(snapshot.Rooms, room)
}
q7Outlines(wire, snapshot)
}
func q7Outlines(wire *mapproto.RobotMap, snapshot *mapmodel.MapSnapshot) {
indexes := q7RoomIndexes(snapshot.Rooms)
for _, outline := range wire.GetRoomOutline() {
index := -1
if outline.RoomId != nil {
if existing, exists := indexes[int64(outline.GetRoomId())]; exists {
index = existing
}
}
if index < 0 {
snapshot.Rooms = append(snapshot.Rooms, mapmodel.MapRoom{
Id: optionalUint(outline.RoomId),
Unit: mapmodel.MapUnitMeters,
Frame: mapmodel.MapFrameQ7World,
Bounds: nil,
CleanState: nil,
Label: nil,
Material: nil,
Name: nil,
Outline: nil,
RoomType: nil,
})
index = len(snapshot.Rooms) - 1
if outline.RoomId != nil {
indexes[int64(outline.GetRoomId())] = index
}
}
if snapshot.Grid.Origin == nil || snapshot.Grid.Resolution == nil {
continue
}
points := q7OutlinePoints(outline, snapshot.Grid)
snapshot.Rooms[index].Outline = &points
snapshot.Rooms[index].Bounds = pointBounds(points)
}
}
func pointBounds(points []mapmodel.MapPoint) *mapmodel.MapRectangle {
if len(points) == 0 {
return nil
}
result := &mapmodel.MapRectangle{
Min: points[0],
Max: points[0],
}
for _, point := range points {
if point.X < result.Min.X {
result.Min.X = point.X
}
if point.Y < result.Min.Y {
result.Min.Y = point.Y
}
if point.X > result.Max.X {
result.Max.X = point.X
}
if point.Y > result.Max.Y {
result.Max.Y = point.Y
}
}
return result
}
func validatePoints(snapshot *mapmodel.MapSnapshot) error {
points := []mapmodel.MapPoint{}
posePoints, err := validatePoses(snapshot)
if err != nil {
return err
}
points = append(points, posePoints...)
for _, path := range snapshot.Paths {
points = append(points, path.Points...)
}
for _, area := range snapshot.Restrictions {
points = append(points, area.Points...)
}
for _, room := range snapshot.Rooms {
if room.Label != nil {
points = append(points, *room.Label)
}
if room.Outline != nil {
points = append(points, (*room.Outline)...)
}
}
for _, point := range points {
if !finite(point.X) || !finite(point.Y) {
return fmt.Errorf("non-finite geometry: %w", ErrMalformed)
}
}
return nil
}
func validatePoses(snapshot *mapmodel.MapSnapshot) ([]mapmodel.MapPoint, error) {
points := []mapmodel.MapPoint{}
for _, pose := range []*mapmodel.MapPose{
snapshot.Robot,
snapshot.Dock,
} {
if pose != nil {
points = append(points, pose.Point)
if pose.Heading != nil && !finite(*pose.Heading) {
return nil, fmt.Errorf("non-finite heading: %w", ErrMalformed)
}
}
}
return points, nil
}
func validateQ7Calibration(grid *mapmodel.MapGrid) error {
if grid.Resolution != nil && (!finite(*grid.Resolution) || *grid.Resolution <= 0) {
return fmt.Errorf("Q7 resolution: %w", ErrMalformed)
}
if grid.Origin != nil && (!finite(grid.Origin.X) || !finite(grid.Origin.Y)) {
return fmt.Errorf("Q7 origin: %w", ErrMalformed)
}
return nil
}
func q7RoomIndexes(rooms []mapmodel.MapRoom) map[int64]int {
indexes := make(map[int64]int, len(rooms))
for index, room := range rooms {
if room.Id != nil {
if _, exists := indexes[*room.Id]; !exists {
indexes[*room.Id] = index
}
}
}
return indexes
}
func q7OutlinePoints(outline *mapproto.RoomOutlineInfo, grid *mapmodel.MapGrid) []mapmodel.MapPoint {
points := make([]mapmodel.MapPoint, 0, len(outline.GetPoints()))
for _, point := range outline.GetPoints() {
if point.X == nil || point.Y == nil {
continue
}
points = append(points, mapmodel.MapPoint{
X: grid.Origin.X + float64(point.GetX())**grid.Resolution,
Y: grid.Origin.Y + float64(point.GetY())**grid.Resolution,
})
}
return points
}
package mapdata
import (
"encoding/binary"
"fmt"
"sort"
"github.com/portpowered/go-roborock/internal/mapmodel"
"github.com/portpowered/go-roborock/internal/protocol"
)
func decodeV1(payload []byte, snapshot *mapmodel.MapSnapshot) error {
if len(payload) < protocol.MapV1HeaderMinimum ||
string(payload[:protocol.MapV1HeaderLengthOffset]) != protocol.MapV1Magic {
return fmt.Errorf("V1 header: %w", ErrMalformed)
}
start := int(binary.LittleEndian.Uint16(payload[protocol.MapV1HeaderLengthOffset:]))
if start < protocol.MapV1HeaderMinimum || start > len(payload) {
return fmt.Errorf("V1 header length: %w", ErrMalformed)
}
snapshot.MapId = pointer(int64(binary.LittleEndian.Uint32(payload[protocol.MapV1MapIDOffset:])))
for start < len(payload) {
if len(payload)-start < protocol.MapBlockHeaderMinimum {
return fmt.Errorf("V1 truncated block header: %w", ErrMalformed)
}
header, length, err := v1BlockBounds(payload[start:])
if err != nil {
return err
}
kind := binary.LittleEndian.Uint16(payload[start : start+protocol.MapBlockTypeSize])
block := payload[start : start+header]
data := payload[start+header : start+header+int(length)]
err = v1Block(kind, block, data, start, snapshot)
if err != nil {
return err
}
start += header + int(length)
}
if snapshot.Grid == nil {
return fmt.Errorf("V1 grid missing: %w", ErrMalformed)
}
return nil
}
func v1Block(kind uint16, header, data []byte, offset int, snapshot *mapmodel.MapSnapshot) error {
switch kind {
case protocol.MapBlockImage:
return v1Grid(header, data, snapshot)
case protocol.MapBlockCharger, protocol.MapBlockRobot:
return v1Pose(kind, data, snapshot)
case protocol.MapBlockPath, protocol.MapBlockGotoPath, protocol.MapBlockPredictedPath:
return v1Path(kind, header, data, snapshot)
case protocol.MapBlockCurrentZones, protocol.MapBlockWalls, protocol.MapBlockNoGo,
protocol.MapBlockNoMop, protocol.MapBlockNoCarpet:
return v1Areas(kind, header, data, snapshot)
default:
snapshot.UnknownBlocks = append(snapshot.UnknownBlocks, mapmodel.MapUnknownBlock{
Type: int64(kind),
Offset: offset,
HeaderLength: len(header),
DataLength: len(data),
})
}
return nil
}
func v1Points(data []byte) []mapmodel.MapPoint {
points := make([]mapmodel.MapPoint, 0, len(data)/protocol.MapV1PointStride)
for start := 0; start < len(data); start += protocol.MapV1PointStride {
points = append(points, mapmodel.MapPoint{
X: float64(binary.LittleEndian.Uint16(data[start : start+protocol.MapV1PointCoordinateSize])),
Y: float64(binary.LittleEndian.Uint16(data[start+protocol.MapV1PointCoordinateSize:])),
})
}
return points
}
func v1Areas(kind uint16, header, data []byte, snapshot *mapmodel.MapSnapshot) error {
if len(header) < protocol.MapV1AreaCountHeaderLength {
return fmt.Errorf("V1 area count missing: %w", ErrMalformed)
}
name, stride := v1AreaKind(kind)
count := int(binary.LittleEndian.Uint16(header[protocol.MapV1AreaCountOffset:]))
if count*stride != len(data) {
return fmt.Errorf("V1 area records: %w", ErrMalformed)
}
for start := 0; start < len(data); start += stride {
area := mapmodel.MapArea{
Kind: name,
Points: v1Points(data[start : start+stride]),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameV1World,
SourceIndex: nil,
SourceStatus: nil,
SourceType: nil,
}
switch kind {
case protocol.MapBlockCurrentZones:
snapshot.CurrentZones = append(snapshot.CurrentZones, area)
case protocol.MapBlockWalls:
snapshot.Walls = append(snapshot.Walls, area)
default:
snapshot.Restrictions = append(snapshot.Restrictions, area)
}
}
return nil
}
func v1Grid(header, data []byte, snapshot *mapmodel.MapSnapshot) error {
if len(header) < protocol.MapV1ImageHeaderMinimum {
return fmt.Errorf("V1 image header: %w", ErrMalformed)
}
fields := header[len(header)-protocol.MapV1ImageFieldsLength:]
width := int(binary.LittleEndian.Uint32(fields[protocol.MapV1ImageWidthOffset:]))
height := int(binary.LittleEndian.Uint32(fields[protocol.MapV1ImageHeightOffset:]))
count, err := cells(width, height)
if err != nil {
return err
}
if len(data) != count {
return fmt.Errorf("V1 image length: %w", ErrMalformed)
}
origin := &mapmodel.MapPoint{
X: float64(binary.LittleEndian.Uint32(fields[protocol.MapV1ImageLeftOffset:])) * protocol.MapV1Resolution,
Y: float64(binary.LittleEndian.Uint32(fields[protocol.MapV1ImageTopOffset:])) * protocol.MapV1Resolution,
}
grid := &mapmodel.MapGrid{
Width: width,
Height: height,
Cells: append([]byte(nil),
data...),
Origin: origin,
Resolution: pointer(float64(protocol.MapV1Resolution)),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameV1World,
TopDown: false,
RowYDirection: 1,
RoomCells: nil,
}
snapshot.Grid = grid
v1Rooms(data, width, origin, snapshot)
return nil
}
func v1Pose(kind uint16, data []byte, snapshot *mapmodel.MapSnapshot) error {
if len(data) != protocol.MapV1PoseLength && len(data) != protocol.MapV1PoseHeadingLength {
return fmt.Errorf("V1 pose length: %w", ErrMalformed)
}
pose := &mapmodel.MapPose{
Point: mapmodel.MapPoint{
X: float64(binary.LittleEndian.Uint32(data[:protocol.MapV1CoordinateSize])),
Y: float64(binary.LittleEndian.Uint32(data[protocol.MapV1CoordinateSize:protocol.MapV1PoseLength])),
},
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameV1World,
Heading: nil,
HeadingUnit: nil,
}
if len(data) == protocol.MapV1PoseHeadingLength {
angle := int64(binary.LittleEndian.Uint32(data[protocol.MapV1PoseHeadingOffset:]))
if angle > protocol.MapV1HeadingMask {
angle = (angle & protocol.MapV1HeadingMask) - protocol.MapV1HeadingWrap
}
pose.Heading = pointer(float64(angle))
pose.HeadingUnit = pointer(mapmodel.Degrees)
}
if kind == protocol.MapBlockRobot {
snapshot.Robot = pose
} else {
snapshot.Dock = pose
}
return nil
}
func v1Path(kind uint16, header, data []byte, snapshot *mapmodel.MapSnapshot) error {
if len(header) < protocol.MapV1PathHeaderLength || len(data)%protocol.MapV1PointStride != 0 {
return fmt.Errorf("V1 path length: %w", ErrMalformed)
}
name := mapmodel.MapPathKindClean
if kind == protocol.MapBlockGotoPath {
name = mapmodel.MapPathKindGoto
}
if kind == protocol.MapBlockPredictedPath {
name = mapmodel.MapPathKindPredicted
}
snapshot.Paths = append(snapshot.Paths, mapmodel.MapPath{
Kind: name,
Points: v1Points(data),
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameV1World,
})
return nil
}
func v1AreaKind(kind uint16) (mapmodel.MapAreaKind, int) {
stride := protocol.MapV1ZoneStride
name := mapmodel.MapAreaKindCurrent
switch kind {
case protocol.MapBlockWalls:
name = mapmodel.MapAreaKindWall
case protocol.MapBlockNoGo:
name = mapmodel.MapAreaKindNoGo
stride = protocol.MapV1PolygonStride
case protocol.MapBlockNoMop:
name = mapmodel.MapAreaKindNoMop
stride = protocol.MapV1PolygonStride
case protocol.MapBlockNoCarpet:
name = mapmodel.MapAreaKindNoCarpet
stride = protocol.MapV1PolygonStride
}
return name, stride
}
func v1Rooms(data []byte, width int, origin *mapmodel.MapPoint, snapshot *mapmodel.MapSnapshot) {
rooms := make(map[int]*mapmodel.MapRectangle)
for cellIndex, value := range data {
if value == protocol.MapV1CellLegacyFloor ||
value == protocol.MapV1CellScan ||
value&protocol.MapV1CellKindMask != protocol.MapV1CellScan {
continue
}
roomID := int(value >> protocol.MapV1RoomIDShift)
point := mapmodel.MapPoint{
X: origin.X + float64(cellIndex%width)*protocol.MapV1Resolution,
Y: origin.Y + float64(cellIndex/width)*protocol.MapV1Resolution,
}
if rooms[roomID] == nil {
rooms[roomID] = &mapmodel.MapRectangle{
Min: point,
Max: point,
}
} else {
rooms[roomID] = pointBounds([]mapmodel.MapPoint{
rooms[roomID].Min,
rooms[roomID].Max,
point,
})
}
}
ids := make([]int, 0, len(rooms))
for roomID := range rooms {
ids = append(ids, roomID)
}
sort.Ints(ids)
for _, roomID := range ids {
snapshot.Rooms = append(snapshot.Rooms, mapmodel.MapRoom{
Id: pointer(int64(roomID)),
Bounds: rooms[roomID],
Unit: mapmodel.MapUnitMillimeters,
Frame: mapmodel.MapFrameV1World,
CleanState: nil,
Label: nil,
Material: nil,
Name: nil,
Outline: nil,
RoomType: nil,
})
}
}
func v1BlockBounds(payload []byte) (int, int64, error) {
header := int(binary.LittleEndian.Uint16(payload[protocol.MapV1HeaderLengthOffset:]))
length := int64(binary.LittleEndian.Uint32(payload[protocol.MapBlockDataLengthOffset:]))
if header < protocol.MapBlockHeaderMinimum || header > len(payload) || length > int64(len(payload)-header) {
return 0, 0, fmt.Errorf("V1 block bounds: %w", ErrMalformed)
}
return header, length, nil
}
package mqtt
import (
"context"
"encoding/json"
"maps"
"strconv"
"time"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
)
type a01Query struct {
expected map[string]struct{}
values map[int]json.RawMessage
ready chan struct{}
}
// QueryA01 merges updates until every requested datapoint arrives.
// Since A01 has no request IDs, the session serializes queries and closes on timeout.
func (s *Session) QueryA01(ctx context.Context, datapoints []int) (map[int]json.RawMessage, error) {
if s.config.Protocol != protocol.MQTTVersionA01 {
return nil, unsupported("query")
}
query, keys, err := newA01Query(datapoints)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
select {
case s.a01Gate <- struct{}{}:
case <-ctx.Done():
return nil, transportError("query", ctx.Err())
case <-s.done:
return nil, s.closedError()
}
defer func() { <-s.a01Gate }()
s.mu.Lock()
s.a01 = query
s.mu.Unlock()
defer s.endA01Query()
err = s.sendA01(ctx, map[string]json.RawMessage{protocol.MQTTQueryDatapoint: keys})
if err != nil {
return nil, err
}
return s.awaitA01(ctx, query)
}
// SetA01 publishes datapoint changes. Success indicates publication, not device acknowledgement or completed action.
func (s *Session) SetA01(ctx context.Context, values map[int]json.RawMessage) error {
if s.config.Protocol != protocol.MQTTVersionA01 {
return unsupported("set")
}
if len(values) == 0 {
return invalid("set", "A01 set requires values")
}
dps := make(map[string]json.RawMessage, len(values))
for key, value := range values {
if key <= 0 || strconv.Itoa(key) == protocol.MQTTQueryDatapoint || !json.Valid(value) {
return invalid("set", "invalid A01 datapoint")
}
dps[strconv.Itoa(key)] = value
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
return s.sendA01(ctx, dps)
}
func newA01Query(datapoints []int) (*a01Query, json.RawMessage, error) {
if len(datapoints) == 0 {
return nil, nil, invalid("query", "A01 query requires datapoints")
}
query := &a01Query{
expected: make(map[string]struct{}), values: make(map[int]json.RawMessage), ready: make(chan struct{}, 1),
}
for _, key := range datapoints {
if key <= 0 {
return nil, nil, invalid("query", "datapoint must be positive")
}
query.expected[strconv.Itoa(key)] = struct{}{}
}
keys, err := json.Marshal(datapoints)
if err != nil {
return nil, nil, transportError("query values", err)
}
encoded, err := json.Marshal(string(keys))
if err != nil {
return nil, nil, transportError("query string", err)
}
return query, encoded, nil
}
func (s *Session) endA01Query() {
s.mu.Lock()
defer s.mu.Unlock()
s.a01 = nil
}
func (s *Session) awaitA01(ctx context.Context, query *a01Query) (map[int]json.RawMessage, error) {
select {
case <-query.ready:
return s.queryResult(query), nil
case <-ctx.Done():
// Closing prevents a late uncorrelated update from satisfying a later query.
s.fail(ctx.Err())
return nil, transportError("query", ctx.Err())
case <-s.done:
select {
case <-query.ready:
return s.queryResult(query), nil
default:
}
return nil, s.closedError()
}
}
func (s *Session) queryResult(query *a01Query) map[int]json.RawMessage {
s.mu.Lock()
defer s.mu.Unlock()
values := make(map[int]json.RawMessage, len(query.values))
maps.Copy(values, query.values)
return values
}
func (s *Session) sendA01(ctx context.Context, dps map[string]json.RawMessage) error {
payload, err := json.Marshal(dependencymodels.MQTTEnvelope{Dps: dps, T: time.Now().Unix()})
if err != nil {
return transportError("A01 envelope", err)
}
err = s.send(ctx, payload)
if err != nil {
return transportError("A01 publish", err)
}
return nil
}
func (s *Session) deliverA01(dps map[string]json.RawMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.a01 == nil {
return nil
}
for key, value := range dps {
index, err := strconv.Atoi(key)
if err != nil {
return errA01DatapointKeyIsNotNumeric
}
if _, expected := s.a01.expected[key]; expected {
s.a01.values[index] = value
}
}
if len(s.a01.values) == len(s.a01.expected) {
select {
case s.a01.ready <- struct{}{}:
default:
}
}
return nil
}
package mqtt
import (
"bytes"
"context"
"crypto/aes"
"encoding/json"
"reflect"
"strconv"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
)
// CallB01 sends one supported Q7 RPC and awaits its correlated response.
func (s *Session) CallB01(
ctx context.Context, method dependencymodels.B01Method, params json.RawMessage,
) (json.RawMessage, error) {
if s.config.Protocol != protocol.B01Version {
return nil, unsupported("B01 call")
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
requestID := int64(protocol.B01Q7MessageIDBase) + int64(s.sequence.Add(1))
reply, err := s.beginRPC(requestID)
if err != nil {
return nil, err
}
defer s.endRPC(requestID)
payload, err := b01RPCPayload(requestID, method, params)
if err != nil {
return nil, err
}
err = s.send(ctx, payload)
if err != nil {
return nil, transportError("B01 call", err)
}
return s.awaitRPC(ctx, reply)
}
func b01RPCPayload(requestID int64, method dependencymodels.B01Method, params json.RawMessage) ([]byte, error) {
switch string(method) {
case protocol.MapsQ7ListMethod, protocol.MapsQ7UploadMethod, protocol.MapsQ7RoomCleanMethod:
default:
return nil, unsupported("B01 method")
}
if len(params) == 0 {
params = json.RawMessage(protocol.MQTTEmptyParamsJSON)
}
if !json.Valid(params) {
return nil, invalid("B01 call", "invalid parameters")
}
request, err := json.Marshal(dependencymodels.B01RPCRequest{
Method: method, MsgId: strconv.FormatInt(requestID, 10), Params: params,
})
if err != nil {
return nil, transportError("B01 request", err)
}
payload, err := json.Marshal(dependencymodels.B01Envelope{
Dps: map[string]json.RawMessage{protocol.B01Q7Datapoint: request},
})
if err != nil {
return nil, transportError("B01 envelope", err)
}
// Q7 pads the JSON envelope once before B01 outer frame encryption pads it again.
padding := aes.BlockSize - len(payload)%aes.BlockSize
return append(payload, bytes.Repeat([]byte{byte(padding)}, padding)...), nil
}
// SetQ10Clean publishes a typed room or zone clean command. Publication does not
// acknowledge device execution, and the command is never retried.
func (s *Session) SetQ10Clean(ctx context.Context, command dependencymodels.Q10CleanCommand) error {
if s.config.Protocol != protocol.B01Version {
return unsupported("Q10 clean")
}
if int(command.Cmd) != protocol.B01Q10RoomCleanCommand && int(command.Cmd) != protocol.B01Q10ZoneCleanCommand {
return invalid("Q10 clean", "unsupported task")
}
if !json.Valid(command.CleanParamters) {
return invalid("Q10 clean", "invalid parameters")
}
value, err := json.Marshal(command)
if err != nil {
return transportError("Q10 clean", err)
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
return s.sendB01DPS(ctx, protocol.B01Q10StartCleanDatapoint, value)
}
func (s *Session) sendB01DPS(ctx context.Context, key string, value json.RawMessage) error {
payload, err := json.Marshal(dependencymodels.B01Envelope{Dps: map[string]json.RawMessage{key: value}})
if err != nil {
return transportError("B01 datapoints", err)
}
return s.send(ctx, payload)
}
func (s *Session) deliverB01(payload []byte) error {
// Q7 replies may carry the inner JSON padding; Q10 replies do not.
if !json.Valid(payload) && len(payload) != 0 {
unpadded, err := unpadPayload(payload)
if err == nil {
payload = unpadded
}
}
var envelope dependencymodels.B01Envelope
err := json.Unmarshal(payload, &envelope)
if err != nil {
return transportError("B01 envelope", err)
}
if envelope.Dps == nil {
return errMissingMQTTDatapoints
}
for _, value := range envelope.Dps {
var inner string
if json.Unmarshal(value, &inner) == nil {
err := s.deliverB01RPC([]byte(inner))
if err != nil {
return err
}
}
}
return s.deliverQ10(envelope.Dps)
}
func (s *Session) deliverB01RPC(value []byte) error {
object, known := knownB01RPCResponse(value)
if !known {
return nil
}
var result dependencymodels.B01RPCResponse
err := json.Unmarshal(value, &result)
if err != nil {
return protocolError("B01 response", err)
}
err = validateB01Code(object)
if err != nil {
return protocolError("B01 response", err)
}
requestID, err := strconv.ParseInt(result.MsgId, 10, 64)
if err != nil || requestID < protocol.B01Q7MessageIDBase ||
requestID >= protocol.B01Q7MessageIDBase+protocol.B01Q7MessageIDRange ||
strconv.FormatInt(requestID, 10) != result.MsgId {
if err != nil {
return transportError("B01 response identifier", err)
}
return transportError("B01 response identifier", errInvalidB01MessageID)
}
s.deliverReply(requestID, b01Result(result))
return nil
}
func b01Result(result dependencymodels.B01RPCResponse) response {
var reply response
if result.Data != nil {
reply.value = *result.Data
if isUnknownMethod(reply.value) {
reply.err = unknownMethodResult("B01 call")
}
}
if result.Code != nil && *result.Code != 0 {
reply.err = rpcRejection(dependencymodels.MQTTRPCError{Code: result.Code, Message: nil})
}
return reply
}
// knownB01RPCResponse separates unsolicited datapoint strings and future objects
// from RPC objects using the canonical generated identifier member.
func knownB01RPCResponse(value []byte) (map[string]json.RawMessage, bool) {
var object map[string]json.RawMessage
if json.Unmarshal(value, &object) != nil {
return nil, false
}
field, _ := reflect.TypeFor[dependencymodels.B01RPCResponse]().FieldByName("MsgId")
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
_, present := object[key]
return object, present
}
func validateB01Code(object map[string]json.RawMessage) error {
field, _ := reflect.TypeFor[dependencymodels.B01RPCResponse]().FieldByName("Code")
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
value, present := object[key]
if present && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return errInvalidB01ResponseShape
}
return nil
}
package mqtt
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/md5" //nolint:gosec // Roborock's published V1 and A01 wire algorithms require MD5.
"encoding/binary"
"encoding/hex"
"fmt"
"hash/crc32"
"math"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
)
const maxCipherPayload = protocol.MQTTMaxCipherPayload
type deviceFrame = dependencymodels.MQTTDeviceFrame
func frameCipher(frame deviceFrame, localKey string) (cipher.Block, []byte, error) {
if frame.Version == protocol.MQTTVersionA01 || frame.Version == protocol.B01Version {
salt, start, end := protocol.MQTTA01Hash, protocol.MQTTA01IVHexStart, protocol.MQTTA01IVHexEnd
if frame.Version == protocol.B01Version {
salt, start, end = protocol.B01Hash, protocol.B01IVHexStart, protocol.B01IVHexEnd
}
// A01 derives its IV from this exact MD5 digest.
ivInput := fmt.Sprintf(protocol.MQTTA01IVInputFormat, frame.Random, salt)
digest := md5.Sum([]byte(ivInput)) //nolint:gosec
block, err := aes.NewCipher([]byte(localKey))
if err != nil {
return nil, nil, fmt.Errorf("create A01 device cipher: %w", err)
}
initializationVector := hex.EncodeToString(digest[:])[start:end]
return block, []byte(initializationVector), nil
}
if frame.Version != protocol.MQTTVersionV1 {
return nil, nil, errUnsupportedDeviceProtocol
}
stamp := fmt.Sprintf(protocol.MQTTTimestampHexFormat, frame.Timestamp)
reordered := make([]byte, 0, len(stamp)+len(localKey)+len(protocol.MQTTWireSalt))
for _, index := range protocol.MQTTTimestampPermutation {
reordered = append(reordered, stamp[index-'0'])
}
// V1 derives its AES key from this exact MD5 digest.
digest := md5.Sum(append(append(reordered, []byte(localKey)...), []byte(protocol.MQTTWireSalt)...)) //nolint:gosec
block, err := aes.NewCipher(digest[:])
if err != nil {
return nil, nil, fmt.Errorf("create V1 device cipher: %w", err)
}
return block, nil, nil
}
func encodeFrame(frame deviceFrame, localKey string) ([]byte, error) {
block, initializationVector, err := frameCipher(frame, localKey)
if err != nil {
return nil, err
}
padding := aes.BlockSize - len(frame.Payload)%aes.BlockSize
plain := append(bytes.Clone(frame.Payload), bytes.Repeat([]byte{byte(padding)}, padding)...)
if len(plain) > maxCipherPayload {
return nil, errDevicePayloadExceedsWireLimit
}
encrypted := make([]byte, len(plain))
if initializationVector != nil {
cipher.NewCBCEncrypter(block, initializationVector).CryptBlocks(encrypted, plain)
} else {
for offset := 0; offset < len(plain); offset += aes.BlockSize {
block.Encrypt(encrypted[offset:offset+aes.BlockSize], plain[offset:offset+aes.BlockSize])
}
}
return marshalFrame(frame, encrypted)
}
func marshalFrame(frame deviceFrame, encrypted []byte) ([]byte, error) {
// Bound the integer conversion at the serialization boundary as well as encryption.
payloadLength := len(encrypted)
if payloadLength < 0 || payloadLength > math.MaxUint16 {
return nil, errDevicePayloadExceedsWireLimit
}
result := make([]byte, protocol.MQTTFrameHeaderSize+len(encrypted)+protocol.MQTTFrameChecksumSize)
copy(result, frame.Version)
binary.BigEndian.PutUint32(result[protocol.MQTTFrameSequenceOffset:protocol.MQTTFrameRandomOffset], frame.Sequence)
binary.BigEndian.PutUint32(result[protocol.MQTTFrameRandomOffset:protocol.MQTTFrameTimestampOffset], frame.Random)
binary.BigEndian.PutUint32(result[protocol.MQTTFrameTimestampOffset:protocol.MQTTFrameProtocolOffset], frame.Timestamp)
protocolField := result[protocol.MQTTFrameProtocolOffset:protocol.MQTTFramePayloadLengthOffset]
binary.BigEndian.PutUint16(protocolField, frame.Protocol)
payloadLengthField := result[protocol.MQTTFramePayloadLengthOffset:protocol.MQTTFramePayloadOffset]
binary.BigEndian.PutUint16(payloadLengthField, uint16(payloadLength))
copy(result[protocol.MQTTFramePayloadOffset:], encrypted)
checksumOffset := len(result) - protocol.MQTTFrameChecksumSize
binary.BigEndian.PutUint32(result[checksumOffset:], crc32.ChecksumIEEE(result[:checksumOffset]))
return result, nil
}
func decodeFrame(data []byte, localKey string) (deviceFrame, int, error) {
var frame deviceFrame
if len(data) < protocol.MQTTFrameHeaderSize+protocol.MQTTFrameChecksumSize {
return frame, 0, errTruncatedDeviceFrame
}
payloadLength := binary.BigEndian.Uint16(data[protocol.MQTTFramePayloadLengthOffset:protocol.MQTTFramePayloadOffset])
size := protocol.MQTTFrameHeaderSize + int(payloadLength) + protocol.MQTTFrameChecksumSize
if size > len(data) {
return frame, 0, errTruncatedDevicePayload
}
checksumOffset := size - protocol.MQTTFrameChecksumSize
if binary.BigEndian.Uint32(data[checksumOffset:size]) != crc32.ChecksumIEEE(data[:checksumOffset]) {
return frame, 0, errInvalidDeviceCRC
}
frame.Version = string(data[:protocol.MQTTFrameSequenceOffset])
frame.Sequence = binary.BigEndian.Uint32(data[protocol.MQTTFrameSequenceOffset:protocol.MQTTFrameRandomOffset])
frame.Random = binary.BigEndian.Uint32(data[protocol.MQTTFrameRandomOffset:protocol.MQTTFrameTimestampOffset])
frame.Timestamp = binary.BigEndian.Uint32(data[protocol.MQTTFrameTimestampOffset:protocol.MQTTFrameProtocolOffset])
frame.Protocol = binary.BigEndian.Uint16(data[protocol.MQTTFrameProtocolOffset:protocol.MQTTFramePayloadLengthOffset])
payload, err := decryptPayload(frame, data[protocol.MQTTFramePayloadOffset:checksumOffset], localKey)
if err != nil {
return frame, 0, err
}
frame.Payload = payload
return frame, size, nil
}
func decryptPayload(frame deviceFrame, encrypted []byte, localKey string) ([]byte, error) {
if len(encrypted) == 0 || len(encrypted)%aes.BlockSize != 0 {
return nil, errInvalidCiphertextLength
}
block, initializationVector, err := frameCipher(frame, localKey)
if err != nil {
return nil, err
}
plain := make([]byte, len(encrypted))
if initializationVector != nil {
cipher.NewCBCDecrypter(block, initializationVector).CryptBlocks(plain, encrypted)
} else {
for offset := 0; offset < len(encrypted); offset += aes.BlockSize {
block.Decrypt(plain[offset:offset+aes.BlockSize], encrypted[offset:offset+aes.BlockSize])
}
}
return unpadPayload(plain)
}
func unpadPayload(plain []byte) ([]byte, error) {
paddingByte := plain[len(plain)-1]
padding := int(paddingByte)
if padding < 1 || padding > aes.BlockSize {
return nil, errInvalidDevicePadding
}
if !bytes.Equal(plain[len(plain)-padding:], bytes.Repeat([]byte{paddingByte}, padding)) {
return nil, errInvalidDevicePadding
}
return plain[:len(plain)-padding], nil
}
package mqtt
import (
"fmt"
"net/url"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
func validateConfig(config Config) (*url.URL, error) {
endpoint, err := url.Parse(config.BrokerURL)
if err != nil {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, "mqtt open", "invalid broker URL", err)
}
err = validateEndpoint(endpoint)
if err != nil {
return nil, err
}
err = validateCredentials(config)
if err != nil {
return nil, err
}
switch config.Protocol {
case "", protocol.MQTTVersionV1, protocol.MQTTVersionA01, protocol.B01Version:
default:
return nil, unsupported("open")
}
return endpoint, nil
}
func validateEndpoint(endpoint *url.URL) error {
if endpoint.Scheme != protocol.MQTTBrokerSchemeSSL && endpoint.Scheme != protocol.MQTTBrokerSchemeMQTTS {
return errMQTTBrokerRequiresTLSSslOrMqtts
}
if endpoint.Hostname() == "" || endpoint.Port() == "" || endpoint.User != nil {
return errInvalidMQTTBrokerURL
}
if endpoint.Path != "" || endpoint.RawQuery != "" || endpoint.Fragment != "" {
return errInvalidMQTTBrokerURL
}
return nil
}
func validateCredentials(config Config) error {
for _, value := range []string{config.User, config.Secret, config.Key, config.DeviceID, config.LocalKey} {
if value == "" || len(value) > protocol.MQTTMaxStringLength || strings.ContainsRune(value, protocol.MQTTNullRune) {
return errInvalidMQTTCredentialsOrDeviceIdentifier
}
}
if strings.ContainsAny(config.User+config.DeviceID, protocol.MQTTForbiddenTopicCharacters) {
return errInvalidMQTTAccountOrDeviceTopicIdentifier
}
topicLength := len(fmt.Sprintf(protocol.MQTTSubscribeTopicFormat, config.User, "", config.DeviceID))
topicLength += protocol.MQTTUsernameDigestHexEnd - protocol.MQTTUsernameDigestHexStart
if topicLength > protocol.MQTTMaxStringLength {
return invalid("open", "device topic exceeds MQTT wire limit")
}
if len(config.LocalKey) != protocol.MQTTLocalKeyBytes {
return errDeviceLocalKeyMustContain16Bytes
}
return nil
}
// Package mqtt implements account-bound Roborock cloud device sessions.
package mqtt
import (
"context"
"errors"
"fmt"
"io"
"net"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// ErrClosed indicates that the session cannot accept further commands.
var ErrClosed = errors.New("MQTT session closed")
func transportError(operation string, cause error) error {
kind := roborockerrors.Protocol
var (
network net.Error
classified *roborockerrors.Error
)
switch {
case errors.As(cause, &classified):
kind = classified.Kind
case errors.Is(cause, context.DeadlineExceeded):
kind = roborockerrors.Timeout
case errors.Is(cause, context.Canceled):
kind = roborockerrors.Canceled
case errors.Is(cause, ErrClosed):
kind = roborockerrors.Closed
case errors.As(cause, &network):
kind = roborockerrors.Unavailable
if network.Timeout() {
kind = roborockerrors.Timeout
cause = errors.Join(context.DeadlineExceeded, cause)
}
case operation == "open":
kind = roborockerrors.InvalidArgument
case operation == "connect":
kind = roborockerrors.Unavailable
case errors.Is(cause, io.EOF):
kind = roborockerrors.Unavailable
}
return roborockerrors.New(kind, "mqtt "+operation, "MQTT operation failed", cause)
}
func protocolError(operation string, cause error) error {
return roborockerrors.New(roborockerrors.Protocol, "mqtt "+operation, "malformed device response", cause)
}
func invalid(operation, message string) error {
return roborockerrors.New(roborockerrors.InvalidArgument, "mqtt "+operation, message, nil)
}
func unsupported(operation string) error {
return roborockerrors.New(roborockerrors.Unsupported, "mqtt "+operation, "unsupported device protocol", nil)
}
// RPCError is a command rejection returned by the device.
type RPCError struct {
Code int
Message string
}
func (e *RPCError) Error() string { return fmt.Sprintf("device RPC %d: %s", e.Code, e.Message) }
var (
errInvalidB01ResponseShape = errors.New("B01 response known field is not nullable")
errInvalidB01MessageID = errors.New("B01 response message identifier exceeds its wire range")
errInvalidMapListShape = errors.New("map list response is missing a required field")
errInvalidTimestamp = errors.New("unix timestamp exceeds device wire range")
errUnexpectedMQTTPacket = errors.New("unexpected MQTT packet")
errA01DatapointKeyIsNotNumeric = errors.New("A01 datapoint key is not numeric")
errBrokerRejectedMQTTConnection = errors.New("broker rejected MQTT connection")
errBrokerRejectedMQTTSubscription = errors.New("broker rejected MQTT subscription")
errDeviceLocalKeyMustContain16Bytes = errors.New("device local key must contain 16 bytes")
errDevicePayloadExceedsWireLimit = errors.New("device payload exceeds wire limit")
errInvalidCiphertextLength = errors.New("invalid ciphertext length")
errInvalidDeviceCRC = errors.New("invalid device CRC")
errInvalidDevicePadding = errors.New("invalid device padding")
errInvalidMQTTAccountOrDeviceTopicIdentifier = errors.New("invalid MQTT account or device topic identifier")
errInvalidMQTTBrokerURL = errors.New("invalid MQTT broker URL")
errInvalidMQTTCredentialsOrDeviceIdentifier = errors.New("invalid MQTT credentials or device identifier")
errInvalidMQTTRemainingLength = errors.New("invalid MQTT remaining length")
errMissingMQTTDatapoints = errors.New("missing MQTT datapoints")
errMQTTBrokerRequiresTLSSslOrMqtts = errors.New("MQTT broker requires TLS (ssl or mqtts)")
errMQTTPacketExceedsLimit = errors.New("MQTT packet exceeds limit")
errRPCResponseHasNoResultOrError = errors.New("RPC response has no result or error")
errTruncatedDeviceFrame = errors.New("truncated device frame")
errTruncatedDevicePayload = errors.New("truncated device payload")
errTruncatedMQTTPacketIdentifier = errors.New("truncated MQTT packet identifier")
errTruncatedMQTTTopic = errors.New("truncated MQTT topic")
errUnexpectedDeviceFrameVersion = errors.New("unexpected device frame version")
errUnsupportedDeviceProtocol = errors.New("unsupported device protocol")
errUnsupportedIncomingMQTTQoS = errors.New("unsupported incoming MQTT QoS")
)
package mqtt
import (
"context"
"io"
"time"
"github.com/portpowered/go-roborock/internal/protocol"
)
func (s *Session) write(ctx context.Context, data []byte) error {
err := s.acquireWriter(ctx)
if err != nil {
return err
}
defer func() { <-s.writeGate }()
deadline, _ := ctx.Deadline()
err = s.conn.SetWriteDeadline(deadline)
if err != nil {
return transportError("write deadline", err)
}
interrupted := make(chan struct{})
stop := context.AfterFunc(ctx, func() { _ = s.conn.SetWriteDeadline(time.Now()); close(interrupted) })
defer func() {
if !stop() {
<-interrupted
s.fail(ctx.Err())
}
}()
return s.writeAll(ctx, data)
}
func (s *Session) acquireWriter(ctx context.Context) error {
if ctx.Err() != nil {
return transportError("write", ctx.Err())
}
select {
case s.writeGate <- struct{}{}:
case <-ctx.Done():
return transportError("write", ctx.Err())
case <-s.done:
return s.closedError()
}
if ctx.Err() != nil {
<-s.writeGate
return transportError("write", ctx.Err())
}
select {
case <-s.done:
<-s.writeGate
return s.closedError()
default:
return nil
}
}
func (s *Session) writeAll(ctx context.Context, data []byte) error {
for len(data) > 0 {
count, err := s.conn.Write(data)
if err != nil {
s.fail(err)
if ctx.Err() != nil {
return transportError("write", ctx.Err())
}
return transportError("write", err)
}
if count == 0 {
s.fail(io.ErrNoProgress)
return io.ErrNoProgress
}
data = data[count:]
}
return nil
}
func (s *Session) keepalive(owner context.Context) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
s.keepaliveTicks(owner, ticker.C)
}
func (s *Session) keepaliveTicks(owner context.Context, ticks <-chan time.Time) {
defer close(s.keepaliveStopped)
for {
select {
case <-s.done:
return
case <-ticks:
ctx, cancel := context.WithTimeout(owner, handshakeTimeout)
err := s.write(ctx, packet(protocol.MQTTPingReq, nil))
cancel()
if err != nil {
s.fail(err)
return
}
}
}
}
func (s *Session) readLoop(owner context.Context) {
defer close(s.stopped)
for {
err := s.conn.SetReadDeadline(time.Now().Add(responseTimeout))
if err != nil {
s.fail(err)
return
}
header, body, err := readPacket(s.conn)
if err != nil {
s.fail(err)
return
}
if header == protocol.MQTTPingResp && len(body) == 0 {
continue
}
if header>>protocol.MQTTKindShift != protocol.MQTTKindPublish {
s.fail(errUnexpectedMQTTPacket)
return
}
err = s.receivePublish(owner, header, body)
if err != nil {
s.fail(err)
return
}
}
}
package mqtt
import (
"bytes"
"compress/gzip"
"compress/zlib"
"crypto/aes"
"crypto/cipher"
"crypto/md5" //nolint:gosec // Q7 map key derivation is mandated by the reference protocol.
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
)
const (
maxMapData = 32 * 1024 * 1024
base64QuantumChars = 4
)
func decodeV1Map(encrypted []byte, nonce string) ([]byte, error) {
key, err := hex.DecodeString(nonce)
if err != nil {
return nil, protocolError("V1 map nonce", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, protocolError("V1 map cipher", err)
}
if len(encrypted) == 0 || len(encrypted)%aes.BlockSize != 0 {
return nil, protocolError("V1 map", errInvalidCiphertextLength)
}
plain := make([]byte, len(encrypted))
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(plain, encrypted)
plain, err = unpadPayload(plain)
if err != nil {
return nil, protocolError("V1 map padding", err)
}
reader, err := gzip.NewReader(bytes.NewReader(plain))
if err != nil {
return nil, protocolError("V1 map gzip", err)
}
return inflateMap(reader)
}
func q7MapKey(serial, model string) ([]byte, error) {
parts := strings.Split(model, ".")
suffix := parts[len(parts)-1]
if serial == "" || suffix == "" || !asciiText(serial+suffix) {
return nil, invalid("Q7 map", "serial and model must be nonempty ASCII")
}
paddedModel := suffix + strings.Repeat(protocol.B01Q7ModelKeyPadding, protocol.B01Q7ModelKeyBytes)
modelKey := paddedModel[:protocol.B01Q7ModelKeyBytes]
block, err := aes.NewCipher([]byte(modelKey))
if err != nil {
return nil, protocolError("Q7 model key", err)
}
material := []byte(fmt.Sprintf(protocol.B01Q7MapKeyInputFormat, serial, suffix, serial))
padding := aes.BlockSize - len(material)%aes.BlockSize
material = append(material, bytes.Repeat([]byte{byte(padding)}, padding)...)
encrypted := make([]byte, len(material))
for offset := 0; offset < len(material); offset += aes.BlockSize {
block.Encrypt(encrypted[offset:offset+aes.BlockSize], material[offset:offset+aes.BlockSize])
}
digest := md5.Sum([]byte(base64.StdEncoding.EncodeToString(encrypted))) //nolint:gosec // Vendor map-key derivation.
return []byte(hex.EncodeToString(digest[:])[protocol.B01Q7MapKeyHexStart:protocol.B01Q7MapKeyHexEnd]), nil
}
func decodeQ7Map(payload, key []byte) ([]byte, error) {
text := strings.TrimSpace(string(payload))
if remainder := len(text) % base64QuantumChars; remainder != 0 {
text += strings.Repeat("=", base64QuantumChars-remainder)
}
encrypted, err := base64.StdEncoding.Strict().DecodeString(text)
if err != nil {
return nil, protocolError("Q7 map base64", err)
}
if len(encrypted) == 0 || len(encrypted)%aes.BlockSize != 0 {
return nil, protocolError("Q7 map", errInvalidCiphertextLength)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, protocolError("Q7 map cipher", err)
}
plain := make([]byte, len(encrypted))
for offset := 0; offset < len(encrypted); offset += aes.BlockSize {
block.Decrypt(plain[offset:offset+aes.BlockSize], encrypted[offset:offset+aes.BlockSize])
}
plain, err = unpadPayload(plain)
if err != nil {
return nil, protocolError("Q7 map padding", err)
}
compressed, err := hex.DecodeString(string(plain))
if err != nil {
return nil, protocolError("Q7 map hex", err)
}
reader, err := zlib.NewReader(bytes.NewReader(compressed))
if err != nil {
return nil, protocolError("Q7 map zlib", err)
}
return inflateMap(reader)
}
func inflateMap(reader io.ReadCloser) ([]byte, error) {
defer func() { _ = reader.Close() }()
data, err := io.ReadAll(io.LimitReader(reader, maxMapData+1))
if err != nil {
return nil, protocolError("map decompression", err)
}
if len(data) > maxMapData {
return nil, protocolError("map decompression", errDevicePayloadExceedsWireLimit)
}
return data, nil
}
func asciiText(text string) bool {
for _, char := range text {
if char > 127 || char < 32 {
return false
}
}
return true
}
package mqtt
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
type mapQuery struct {
id int64
prefix byte
reply chan response
}
// FetchMapV1 retrieves the current V1 map, decrypting and inflating its payload.
func (s *Session) FetchMapV1(ctx context.Context) ([]byte, error) {
if s.config.Protocol != protocol.MQTTVersionV1 {
return nil, unsupported("V1 map")
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
query, err := s.beginMap(ctx, 0)
if err != nil {
return nil, err
}
defer s.endMap()
err = s.registerMapRPC(query, false)
if err != nil {
return nil, err
}
payload, err := s.rpcPayload(query.id, protocol.MapsV1GetMethod, json.RawMessage(protocol.MQTTEmptyParamsJSON))
if err != nil {
return nil, err
}
err = s.send(ctx, payload)
if err != nil {
return nil, transportError("V1 map", err)
}
return s.awaitMap(ctx, query)
}
// FetchMapQ7 requests a saved Q7 map by identifier and returns the next observed
// map push while pending. Pushes carry no request ID, so causal freshness cannot
// be established. Serial and product model supply the map key derivation.
func (s *Session) FetchMapQ7(ctx context.Context, mapID int64, serial, model string) ([]byte, error) {
if s.config.Protocol != protocol.B01Version {
return nil, unsupported("Q7 map")
}
key, err := q7MapKey(serial, model)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
query, err := s.beginMap(ctx, 0)
if err != nil {
return nil, err
}
defer s.endMap()
params, err := json.Marshal(dependencymodels.MapsQ7UploadRequest{MapId: mapID})
if err != nil {
return nil, transportError("Q7 map parameters", err)
}
err = s.registerMapRPC(query, true)
if err != nil {
return nil, err
}
payload, err := b01RPCPayload(query.id, dependencymodels.ServiceUploadByMapid, params)
if err != nil {
return nil, err
}
err = s.send(ctx, payload)
if err != nil {
return nil, transportError("Q7 map", err)
}
raw, err := s.awaitMap(ctx, query)
if err != nil {
return nil, err
}
return decodeQ7Map(raw, key)
}
// FetchMapQ10 sends read-only DP102 and returns the next observed current-map
// push while pending. The uncorrelated push cannot establish causal freshness.
// The returned inner payload retains the Q10 framing and compression.
func (s *Session) FetchMapQ10(ctx context.Context) ([]byte, error) {
return s.fetchQ10(ctx, protocol.MapsQ10CurrentPrefix)
}
// FetchTraceQ10 sends read-only DP102 and returns the next observed trace push
// while pending, without a causal freshness guarantee. Its coordinates and
// timestamp are independent of a separately retrieved current map.
func (s *Session) FetchTraceQ10(ctx context.Context) ([]byte, error) {
return s.fetchQ10(ctx, protocol.MapsQ10TracePrefix)
}
func (s *Session) fetchQ10(ctx context.Context, prefix byte) ([]byte, error) {
if s.config.Protocol != protocol.B01Version {
return nil, unsupported("Q10 map")
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
query, err := s.beginMap(ctx, prefix)
if err != nil {
return nil, err
}
defer s.endMap()
err = s.sendB01DPS(ctx, protocol.B01Q10CurrentMapDatapoint, json.RawMessage(protocol.B01EmptyObjectJSON))
if err != nil {
return nil, transportError("Q10 map", err)
}
return s.awaitMap(ctx, query)
}
func (s *Session) beginMap(ctx context.Context, prefix byte) (*mapQuery, error) {
select {
case s.mapGate <- struct{}{}:
case <-ctx.Done():
return nil, transportError("map", ctx.Err())
case <-s.done:
return nil, s.closedError()
}
query := &mapQuery{id: 0, reply: make(chan response, 1), prefix: prefix}
s.mu.Lock()
s.mapQuery = query
s.mu.Unlock()
return query, nil
}
func (s *Session) endMap() {
s.mu.Lock()
if query := s.mapQuery; query != nil && query.id != 0 {
delete(s.pending, query.id)
}
s.mapQuery = nil
s.mu.Unlock()
<-s.mapGate
}
func (s *Session) registerMapRPC(query *mapQuery, isQ7 bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.pending) >= maxPendingCommands {
return roborockerrors.New(roborockerrors.Backpressure, "mqtt map", "too many pending commands", nil)
}
for range maxPendingCommands + 1 {
sequence := s.sequence.Add(1)
requestID := int64((sequence-1)%protocol.MapsV1RequestIDRange) + 1
if isQ7 {
requestID = int64(protocol.B01Q7MessageIDBase) + int64(sequence)
}
if _, exists := s.pending[requestID]; !exists {
query.id = requestID
// A nil channel reserves the ID without allowing successful RPC ACKs
// to replace the binary map completion.
s.pending[requestID] = nil
return nil
}
}
return roborockerrors.New(roborockerrors.Backpressure, "mqtt map", "no available map request identifier", nil)
}
func (s *Session) awaitMap(ctx context.Context, query *mapQuery) ([]byte, error) {
select {
case reply := <-query.reply:
return reply.value, reply.err
case <-ctx.Done():
if s.config.Protocol == protocol.B01Version {
// An uncorrelated late response must not satisfy another map fetch.
s.fail(ctx.Err())
}
return nil, transportError("map", ctx.Err())
case <-s.done:
return nil, s.closedError()
}
}
func (s *Session) deliverMap(payload []byte) error {
s.mu.Lock()
query := s.mapQuery
if query == nil {
s.mu.Unlock()
return nil
}
matched, matchErr := s.matchesMap(payload, query)
if matchErr != nil || !matched {
s.mu.Unlock()
return matchErr
}
s.mu.Unlock()
value := bytes.Clone(payload)
var err error
if s.config.Protocol == protocol.MQTTVersionV1 {
value, err = decodeV1Map(payload[protocol.MapsV1HeaderSize:], s.security.Nonce)
}
select {
case query.reply <- response{value: value, err: err}:
default:
}
return nil
}
func (s *Session) matchesMap(payload []byte, query *mapQuery) (bool, error) {
if s.config.Protocol == protocol.MQTTVersionV1 {
if len(payload) < protocol.MapsV1HeaderSize {
return false, transportError("V1 map header", errTruncatedDevicePayload)
}
endpoint := payload[protocol.MapsV1EndpointOffset:protocol.MapsV1EndpointEnd]
requestID := binary.LittleEndian.Uint16(payload[protocol.MapsV1RequestIDOffset:protocol.MapsV1RequestIDEnd])
return bytes.HasPrefix(endpoint, []byte(s.security.Endpoint)) && int64(requestID) == query.id, nil
}
if query.prefix == 0 {
return true, nil
}
return len(payload) >= protocol.MapsQ10PrefixSize && payload[0] == query.prefix &&
payload[1] == protocol.MapsQ10PrefixVersion, nil
}
package mqtt
import (
"context"
"crypto/md5" //nolint:gosec // Roborock's mandated account authentication uses MD5.
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"net"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// Open establishes a device connection without reconnecting or retrying commands.
// The context governs establishment. Callers own the returned session until Close.
func Open(ctx context.Context, config Config, dial DialFunc) (*Session, error) {
endpoint, err := validateConfig(config)
if err != nil {
return nil, transportError("open", err)
}
if config.Protocol == "" {
config.Protocol = protocol.MQTTVersionV1
}
if dial == nil {
dial = defaultDial
}
opening, cancel := context.WithTimeout(ctx, handshakeTimeout)
defer cancel()
conn, err := dial(opening, protocol.MQTTNetwork, endpoint.Host)
if err != nil {
return nil, transportError("connect", err)
}
if conn == nil {
return nil, invalid("connect", "dialer returned no connection")
}
session, username, password, err := newSession(conn, config)
if err != nil {
_ = conn.Close()
return nil, err
}
stop := context.AfterFunc(opening, func() { session.fail(opening.Err()) })
defer stop()
err = session.handshake(opening, username, password)
if err != nil {
session.fail(err)
if opening.Err() != nil {
err = opening.Err()
}
return nil, transportError("handshake", err)
}
if !stop() {
session.fail(opening.Err())
return nil, transportError("handshake", opening.Err())
}
owner := context.WithoutCancel(ctx)
go session.readLoop(owner)
go session.keepalive(owner)
return session, nil
}
func newSession(conn net.Conn, config Config) (*Session, string, string, error) {
var session Session
session.conn = conn
session.config = config
session.done = make(chan struct{})
session.stopped = make(chan struct{})
session.keepaliveStopped = make(chan struct{})
session.writeGate = make(chan struct{}, 1)
session.a01Gate = make(chan struct{}, 1)
session.mapGate = make(chan struct{}, 1)
session.pending = make(map[int64]chan response)
var nonce [protocol.MQTTSecurityNonceBytes]byte
_, err := rand.Read(nonce[:])
if err != nil {
return nil, "", "", transportError("security", err)
}
endpointHash := md5.Sum([]byte(config.Key)) //nolint:gosec // Wire security endpoint uses MD5 bytes 8:14.
session.security = dependencymodels.MQTTRPCSecurity{
Endpoint: base64.StdEncoding.EncodeToString(
endpointHash[protocol.MQTTSecurityEndpointDigestStart:protocol.MQTTSecurityEndpointDigestEnd]),
Nonce: hex.EncodeToString(nonce[:]),
}
username, password := mqttCredentials(config)
session.publishTopic = fmt.Sprintf(protocol.MQTTPublishTopicFormat, config.User, username, config.DeviceID)
session.subscribeTopic = fmt.Sprintf(protocol.MQTTSubscribeTopicFormat, config.User, username, config.DeviceID)
return &session, username, password, nil
}
func mqttCredentials(config Config) (string, string) {
userInput := fmt.Sprintf(protocol.MQTTAccountDigestInputFormat, config.User, config.Key)
secretInput := fmt.Sprintf(protocol.MQTTAccountDigestInputFormat, config.Secret, config.Key)
userHash := md5.Sum([]byte(userInput)) //nolint:gosec // Vendor username derivation.
secretHash := md5.Sum([]byte(secretInput)) //nolint:gosec // Vendor password derivation.
username := hex.EncodeToString(userHash[:])[protocol.MQTTUsernameDigestHexStart:protocol.MQTTUsernameDigestHexEnd]
password := hex.EncodeToString(secretHash[:])[protocol.MQTTPasswordDigestHexStart:]
return username, password
}
func defaultDial(ctx context.Context, network, address string) (net.Conn, error) {
var config tls.Config
config.MinVersion = tls.VersionTLS12
dialer := tls.Dialer{NetDialer: nil, Config: &config}
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, transportError("connect", err)
}
return conn, nil
}
func (s *Session) handshake(ctx context.Context, username, password string) error {
var client [protocol.MQTTClientRandomBytes]byte
_, err := rand.Read(client[:])
if err != nil {
return transportError("client ID", err)
}
err = s.write(ctx, connectPacket(hex.EncodeToString(client[:]), username, password))
if err != nil {
return err
}
header, body, err := readPacket(s.conn)
if err != nil {
return err
}
err = checkConnAck(header, body)
if err != nil {
return err
}
err = s.write(ctx, subscribePacket(s.subscribeTopic))
if err != nil {
return err
}
header, body, err = readPacket(s.conn)
if err != nil {
return err
}
return checkSubAck(header, body)
}
func checkConnAck(header byte, body []byte) error {
if header != protocol.MQTTConnAck || len(body) != protocol.MQTTConnAckLength {
return errBrokerRejectedMQTTConnection
}
code := body[protocol.MQTTConnAckReturnCodeOffset]
if code == protocol.MQTTBadUsernamePassword || code == protocol.MQTTNotAuthorized {
return roborockerrors.New(roborockerrors.Unauthorized, "mqtt handshake", "broker rejected credentials", nil)
}
if body[protocol.MQTTConnAckSessionOffset] != protocol.MQTTConnAckNoSession || code != protocol.MQTTConnAckAccepted {
return errBrokerRejectedMQTTConnection
}
return nil
}
func checkSubAck(header byte, body []byte) error {
if header != protocol.MQTTSubAck || len(body) != protocol.MQTTSubAckLength {
return errBrokerRejectedMQTTSubscription
}
identifier := binary.BigEndian.Uint16(body[:protocol.MQTTUint16Size])
if identifier != protocol.MQTTSubscribeIdentifier || body[protocol.MQTTUint16Size] != protocol.MQTTQoSAtMostOnce {
return errBrokerRejectedMQTTSubscription
}
return nil
}
package mqtt
import (
"encoding/binary"
"github.com/portpowered/go-roborock/internal/protocol"
"io"
)
const maxPacketSize = 1 << 20
func mqttString(value string) []byte {
result := make([]byte, protocol.MQTTUint16Size+len(value))
length := len(value)
if length < 0 || length > protocol.MQTTMaxStringLength {
return nil
}
binary.BigEndian.PutUint16(result, uint16(length))
copy(result[protocol.MQTTUint16Size:], value)
return result
}
func packet(header byte, body []byte) []byte {
result := []byte{header}
remaining := len(body)
for {
digit := byte(remaining % protocol.MQTTVarintBase)
remaining /= protocol.MQTTVarintBase
if remaining > 0 {
digit |= protocol.MQTTVarintContinuation
}
result = append(result, digit)
if remaining == 0 {
break
}
}
return append(result, body...)
}
func readPacket(reader io.Reader) (byte, []byte, error) {
var header [protocol.MQTTOctetSize]byte
_, err := io.ReadFull(reader, header[:])
if err != nil {
return 0, nil, transportError("packet header", err)
}
length := 0
multiplier := 1
for range protocol.MQTTMaxRemainingLengthOctets {
var digit [protocol.MQTTOctetSize]byte
_, err = io.ReadFull(reader, digit[:])
if err != nil {
return 0, nil, transportError("remaining length", err)
}
length += int(digit[0]&protocol.MQTTVarintMask) * multiplier
if length > maxPacketSize {
return 0, nil, errMQTTPacketExceedsLimit
}
if digit[0]&protocol.MQTTVarintContinuation == 0 {
body := make([]byte, length)
_, err = io.ReadFull(reader, body)
if err != nil {
return 0, nil, transportError("packet body", err)
}
return header[0], body, nil
}
multiplier *= protocol.MQTTVarintBase
}
return 0, nil, errInvalidMQTTRemainingLength
}
func connectPacket(clientID, username, password string) []byte {
body := append(mqttString(protocol.MQTTProtocolName), protocol.MQTTProtocolLevel, protocol.MQTTConnectFlags)
keepalive := make([]byte, protocol.MQTTUint16Size)
binary.BigEndian.PutUint16(keepalive, protocol.MQTTKeepaliveSeconds)
body = append(body, keepalive...)
body = append(body, mqttString(clientID)...)
body = append(body, mqttString(username)...)
body = append(body, mqttString(password)...)
return packet(protocol.MQTTConnect, body)
}
func subscribePacket(topic string) []byte {
var identifier [protocol.MQTTUint16Size]byte
binary.BigEndian.PutUint16(identifier[:], protocol.MQTTSubscribeIdentifier)
body := append(identifier[:], mqttString(topic)...)
return packet(protocol.MQTTSubscribe, append(body, protocol.MQTTQoSAtMostOnce))
}
func publishPacket(topic string, payload []byte) []byte {
return packet(protocol.MQTTPublish, append(mqttString(topic), payload...))
}
package mqtt
import (
"bytes"
"context"
"encoding/json"
"maps"
"reflect"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
)
type q10Query struct{ reply chan response }
// QueryMapListQ10 requests DP61 op:list inside DP101 and returns the next observed
// list reply while pending. This protocol has no request ID or freshness guarantee.
// It never sends the movement-triggering DP61 op:get request.
func (s *Session) QueryMapListQ10(ctx context.Context) (dependencymodels.MapsQ10ListResult, error) {
var result dependencymodels.MapsQ10ListResult
if s.config.Protocol != protocol.B01Version {
return result, unsupported("Q10 maps")
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
_, err := s.beginMap(ctx, 0)
if err != nil {
return result, err
}
defer s.endMap()
query := &q10Query{reply: make(chan response, 1)}
s.mu.Lock()
s.q10Query = query
s.mapQuery = nil
s.mu.Unlock()
defer s.endQ10()
err = s.sendB01DPS(ctx, protocol.B01Q10CommonDatapoint, json.RawMessage(protocol.B01Q10MapListRequestJSON))
if err != nil {
return result, transportError("Q10 maps", err)
}
select {
case reply := <-query.reply:
if reply.err != nil {
return result, reply.err
}
err = json.Unmarshal(reply.value, &result)
if err != nil {
return result, transportError("Q10 maps", err)
}
return result, nil
case <-ctx.Done():
s.fail(ctx.Err())
return result, transportError("Q10 maps", ctx.Err())
case <-s.done:
return result, s.closedError()
}
}
func (s *Session) endQ10() {
s.mu.Lock()
s.q10Query = nil
s.mu.Unlock()
}
func (s *Session) deliverQ10(dps map[string]json.RawMessage) error {
err := expandQ10DPS(dps)
if err != nil {
return protocolError("Q10 map list shape", err)
}
return s.deliverQ10List(dps[protocol.B01Q10MapListDatapoint])
}
func expandQ10DPS(dps map[string]json.RawMessage) error {
if common := dps[protocol.B01Q10CommonDatapoint]; common != nil {
var values map[string]json.RawMessage
err := json.Unmarshal(common, &values)
if err != nil {
return transportError("Q10 common datapoints", err)
}
maps.Copy(dps, values)
}
return nil
}
func (s *Session) deliverQ10List(value json.RawMessage) error {
if value == nil {
return nil
}
var result dependencymodels.MapsQ10ListResult
err := json.Unmarshal(value, &result)
if err != nil {
return transportError("Q10 map list", err)
}
if result.Op == nil || *result.Op != protocol.B01Q10MapListOperation {
return nil
}
err = validateQ10MapListEntries(value)
if err != nil {
return transportError("Q10 map list", err)
}
reply := response{value: value, err: nil}
reply.err = q10ListRejection(result)
s.mu.Lock()
defer s.mu.Unlock()
if query := s.q10Query; query != nil {
select {
case query.reply <- reply:
default:
}
}
return nil
}
// validateQ10MapListEntries checks required identifier presence before decoding
// loses that distinction. JSON tags come from the canonical generated wire model.
func validateQ10MapListEntries(value json.RawMessage) error {
var object map[string]json.RawMessage
err := json.Unmarshal(value, &object)
if err != nil {
return protocolError("Q10 map list shape", err)
}
dataField, _ := reflect.TypeFor[dependencymodels.MapsQ10ListResult]().FieldByName("Data")
dataKey, _, _ := strings.Cut(dataField.Tag.Get("json"), ",")
data, present := object[dataKey]
if !present {
return nil
}
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
return errInvalidMapListShape
}
var entries []map[string]json.RawMessage
err = json.Unmarshal(data, &entries)
if err != nil {
return protocolError("Q10 map list shape", err)
}
for _, entry := range entries {
err = validateQ10MapListEntry(entry)
if err != nil {
return err
}
}
return nil
}
func q10ListRejection(result dependencymodels.MapsQ10ListResult) error {
if result.Result == nil {
return protocolError("Q10 map list", errInvalidMapListShape)
}
if *result.Result == protocol.B01Q10MapListSuccess {
return nil
}
return protocolError("Q10 map list", &RPCError{Code: *result.Result, Message: "device rejected map list request"})
}
func validateQ10MapListEntry(entry map[string]json.RawMessage) error {
model := reflect.TypeFor[dependencymodels.MapsQ10ListEntry]()
for index := range model.NumField() {
tag := model.Field(index).Tag.Get("json")
key, options, _ := strings.Cut(tag, ",")
value, present := entry[key]
if !present && options != "omitempty" {
return errInvalidMapListShape
}
if present && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return errInvalidMapListShape
}
}
return nil
}
package mqtt
import (
"encoding/base64"
"encoding/binary"
"math"
"github.com/portpowered/go-roborock/internal/protocol"
)
// Q10Zone describes one rectangular zone in the reference millimetre frame,
// whose dock origin is (25500, 25500). Coordinates must align to a 5 mm grid.
type Q10Zone struct {
X1 int64
Y1 int64
X2 int64
Y2 int64
Repeats int
}
// EncodeQ10Zone encodes the reference Q10 zone payload for a typed clean command.
func EncodeQ10Zone(zone Q10Zone) (string, error) {
if zone.Repeats < protocol.Q10ZoneMinRepeat || zone.Repeats > protocol.Q10ZoneMaxRepeat ||
zone.X1 == zone.X2 || zone.Y1 == zone.Y2 {
return "", invalid("Q10 zone", "zone must have area and repeat count from 1 through 3")
}
values := []int64{
min(zone.X1, zone.X2), min(zone.Y1, zone.Y2), max(zone.X1, zone.X2), min(zone.Y1, zone.Y2),
max(zone.X1, zone.X2), max(zone.Y1, zone.Y2), min(zone.X1, zone.X2), max(zone.Y1, zone.Y2),
}
size := protocol.Q10ZoneHeaderSize + protocol.Q10ZoneVertices*protocol.Q10ZoneVertexBytes +
protocol.Q10ZoneNameLengthBytes + protocol.Q10ZoneNameBytes
payload := make([]byte, size)
copy(payload, []byte{protocol.Q10ZoneVersion, byte(zone.Repeats), protocol.Q10ZoneCount, protocol.Q10ZoneVertices})
for index, value := range values {
// Check before subtraction so extreme user coordinates cannot overflow.
if value < protocol.Q10ZoneCoordinateOffsetMM+math.MinInt16*protocol.Q10ZoneVectorUnitMM ||
value > protocol.Q10ZoneCoordinateOffsetMM+math.MaxInt16*protocol.Q10ZoneVectorUnitMM {
return "", invalid("Q10 zone", "coordinate outside int16 vector range")
}
relative := value - protocol.Q10ZoneCoordinateOffsetMM
if relative%protocol.Q10ZoneVectorUnitMM != 0 {
return "", invalid("Q10 zone", "coordinate must align to the 5 mm grid")
}
offset := protocol.Q10ZoneHeaderSize + index*protocol.Q10ZoneCoordinateBytes
coordinate := uint16(int16(relative / protocol.Q10ZoneVectorUnitMM)) //nolint:gosec // Signed range checked above.
binary.BigEndian.PutUint16(payload[offset:offset+protocol.Q10ZoneCoordinateBytes], coordinate)
}
return base64.StdEncoding.EncodeToString(payload), nil
}
package mqtt
import (
"context"
"encoding/binary"
"encoding/json"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
func (s *Session) receivePublish(owner context.Context, header byte, body []byte) error {
if len(body) < protocol.MQTTUint16Size {
return errTruncatedMQTTTopic
}
topicLength := int(binary.BigEndian.Uint16(body[:protocol.MQTTUint16Size]))
if topicLength > len(body)-protocol.MQTTUint16Size {
return errTruncatedMQTTTopic
}
topic := string(body[protocol.MQTTUint16Size : protocol.MQTTUint16Size+topicLength])
payload, err := s.acknowledgePublish(owner, header, body[protocol.MQTTUint16Size+topicLength:])
if err != nil {
return err
}
if topic != s.subscribeTopic {
return nil
}
return s.receiveDeviceFrames(payload)
}
func (s *Session) acknowledgePublish(owner context.Context, header byte, payload []byte) ([]byte, error) {
qos := (header >> protocol.MQTTQoSShift) & protocol.MQTTQoSMask
if qos > protocol.MQTTQoSAtLeastOnce {
return nil, errUnsupportedIncomingMQTTQoS
}
if qos == protocol.MQTTQoSAtMostOnce {
return payload, nil
}
if len(payload) < protocol.MQTTUint16Size {
return nil, errTruncatedMQTTPacketIdentifier
}
ack := packet(protocol.MQTTPubAck, payload[:protocol.MQTTUint16Size])
ctx, cancel := context.WithTimeout(owner, handshakeTimeout)
defer cancel()
err := s.write(ctx, ack)
if err != nil {
return nil, err
}
return payload[protocol.MQTTUint16Size:], nil
}
func (s *Session) receiveDeviceFrames(payload []byte) error {
for len(payload) > 0 {
frame, consumed, err := decodeFrame(payload, s.config.LocalKey)
if err != nil {
return err
}
if frame.Version != s.config.Protocol {
return errUnexpectedDeviceFrameVersion
}
if frame.Protocol == protocol.MQTTProtocolResponse {
err = s.deliver(frame.Payload)
if err != nil {
return err
}
}
if frame.Protocol == protocol.MapsProtocolResponse {
err = s.deliverMap(frame.Payload)
if err != nil {
return err
}
}
payload = payload[consumed:]
}
return nil
}
func (s *Session) deliver(payload []byte) error {
if s.config.Protocol == protocol.B01Version {
return s.deliverB01(payload)
}
var envelope dependencymodels.MQTTEnvelope
err := json.Unmarshal(payload, &envelope)
if err != nil {
return transportError("envelope", err)
}
if envelope.Dps == nil {
return errMissingMQTTDatapoints
}
if s.config.Protocol == protocol.MQTTVersionA01 {
return s.deliverA01(envelope.Dps)
}
encoded, ok := envelope.Dps[protocol.MQTTRPCResponseDatapoint]
if !ok {
return nil
}
result, err := decodeRPCResponse(encoded)
if err != nil {
return err
}
s.deliverReply(result.Id, rpcResult(result))
return nil
}
func (s *Session) deliverReply(requestID int64, reply response) {
s.mu.Lock()
defer s.mu.Unlock()
if query := s.mapQuery; query != nil && query.id == requestID && reply.err != nil {
select {
case query.reply <- reply:
default:
}
return
}
if pending := s.pending[requestID]; pending != nil {
select {
case pending <- reply:
default:
}
}
}
func decodeRPCResponse(encoded json.RawMessage) (dependencymodels.MQTTRPCResponse, error) {
var (
result dependencymodels.MQTTRPCResponse
inner string
)
err := json.Unmarshal(encoded, &inner)
if err != nil {
return result, transportError("RPC envelope", err)
}
err = json.Unmarshal([]byte(inner), &result)
if err != nil {
return result, transportError("RPC response", err)
}
if result.Id <= 0 || (result.Result == nil && result.Error == nil) {
return result, errRPCResponseHasNoResultOrError
}
return result, nil
}
func rpcResult(result dependencymodels.MQTTRPCResponse) response {
var reply response
if result.Result != nil {
reply.value = *result.Result
if isUnknownMethod(reply.value) {
reply.err = unknownMethodResult("call")
}
}
if result.Error != nil {
reply.err = rpcRejection(*result.Error)
}
return reply
}
func isUnknownMethod(value json.RawMessage) bool {
var result string
return json.Unmarshal(value, &result) == nil && result == protocol.MQTTUnknownMethodResult
}
func unknownMethodResult(operation string) error {
return roborockerrors.New(roborockerrors.Unsupported, "mqtt "+operation, "device does not recognize RPC method",
&RPCError{Code: 0, Message: protocol.MQTTUnknownMethodResult})
}
func rpcRejection(rejection dependencymodels.MQTTRPCError) error {
code := 0
message := "device rejected command"
if rejection.Code != nil {
code = *rejection.Code
}
if rejection.Message != nil {
message = *rejection.Message
}
return &roborockerrors.Error{
Kind: roborockerrors.Protocol, Operation: "mqtt call", Code: code, Message: "device rejected command",
Cause: &RPCError{Code: code, Message: message},
}
}
package mqtt
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"math"
"regexp"
"time"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// Call sends one V1 device RPC and awaits its correlated response without retries.
func (s *Session) Call(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error) {
if s.config.Protocol != protocol.MQTTVersionV1 {
return nil, unsupported("call")
}
if !validMethod(method) || (len(params) > 0 && !json.Valid(params)) {
return nil, invalid("call", "invalid RPC method or params")
}
if len(params) == 0 {
params = json.RawMessage(protocol.MQTTEmptyParamsJSON)
}
ctx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
requestID := int64(s.sequence.Add(1))
reply, err := s.beginRPC(requestID)
if err != nil {
return nil, err
}
defer s.endRPC(requestID)
payload, err := s.rpcPayload(requestID, method, params)
if err != nil {
return nil, err
}
err = s.send(ctx, payload)
if err != nil {
return nil, transportError("call", err)
}
return s.awaitRPC(ctx, reply)
}
func (s *Session) beginRPC(requestID int64) (chan response, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.pending) >= maxPendingCommands {
return nil, roborockerrors.New(roborockerrors.Backpressure, "mqtt call", "too many pending commands", nil)
}
if _, exists := s.pending[requestID]; exists {
return nil, roborockerrors.New(roborockerrors.Backpressure, "mqtt call", "request identifier already pending", nil)
}
reply := make(chan response, 1)
s.pending[requestID] = reply
return reply, nil
}
func (s *Session) endRPC(requestID int64) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.pending, requestID)
}
func (s *Session) rpcPayload(requestID int64, method string, params json.RawMessage) ([]byte, error) {
request := dependencymodels.MQTTRPCRequest{Id: requestID, Method: method, Params: params, Security: s.security}
inner, err := json.Marshal(request)
if err != nil {
return nil, transportError("request", err)
}
text, err := json.Marshal(string(inner))
if err != nil {
return nil, transportError("request string", err)
}
envelope := dependencymodels.MQTTEnvelope{
Dps: map[string]json.RawMessage{protocol.MQTTRPCRequestDatapoint: text}, T: time.Now().Unix(),
}
payload, err := json.Marshal(envelope)
if err != nil {
return nil, transportError("request envelope", err)
}
return payload, nil
}
func (s *Session) awaitRPC(ctx context.Context, reply <-chan response) (json.RawMessage, error) {
select {
case result := <-reply:
return result.value, result.err
case <-ctx.Done():
return nil, transportError("call", ctx.Err())
case <-s.done:
select {
case result := <-reply:
return result.value, result.err
default:
}
return nil, s.closedError()
}
}
func (s *Session) send(ctx context.Context, payload []byte) error {
var random [protocol.MQTTFrameTimestampOffset - protocol.MQTTFrameRandomOffset]byte
_, err := rand.Read(random[:])
if err != nil {
return transportError("frame nonce", err)
}
timestamp := time.Now().Unix()
if timestamp < 0 || timestamp > math.MaxUint32 {
return transportError("frame timestamp", errInvalidTimestamp)
}
frame := deviceFrame{
Version: s.config.Protocol, Sequence: s.sequence.Add(1), Random: binary.BigEndian.Uint32(random[:]),
Timestamp: uint32(timestamp), Protocol: protocol.MQTTProtocolRequest, Payload: payload,
}
encoded, err := encodeFrame(frame, s.config.LocalKey)
if err != nil {
return err
}
return s.write(ctx, publishPacket(s.publishTopic, encoded))
}
var methodPattern = regexp.MustCompile(protocol.MQTTRPCMethodPattern)
func validMethod(method string) bool { return methodPattern.MatchString(method) }
package mqtt
import (
"context"
"encoding/json"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"net"
"sync"
"sync/atomic"
"time"
)
const (
commandTimeout = 10 * time.Second
handshakeTimeout = 10 * time.Second
pingInterval = 20 * time.Second
responseTimeout = 60 * time.Second
maxPendingCommands = 64
)
// Config binds a device session to one account and device encryption key.
type Config struct {
BrokerURL string
User string
Secret string
Key string
DeviceID string
LocalKey string
// Protocol is "1.0" (vacuum/camera), "A01" (wet cleaner/washer), or "B01" (Q7/Q10).
Protocol string
}
// DialFunc replaces the socket creation, including TLS, for offline replay.
type DialFunc func(context.Context, string, string) (net.Conn, error)
type response struct {
value json.RawMessage
err error
}
// Session owns a single MQTT connection and all pending device requests.
type Session struct {
conn net.Conn
config Config
publishTopic string
subscribeTopic string
done chan struct{}
stopped chan struct{}
keepaliveStopped chan struct{}
closeOnce sync.Once
writeGate chan struct{}
a01Gate chan struct{}
mapGate chan struct{}
mapQuery *mapQuery
q10Query *q10Query
sequence atomic.Uint32
mu sync.Mutex
pending map[int64]chan response
a01 *a01Query
failure error
security dependencymodels.MQTTRPCSecurity
}
// Done closes when the session ends or the connection fails.
func (s *Session) Done() <-chan struct{} { return s.done }
// Err returns the terminal failure after Done closes, or nil while active.
func (s *Session) Err() error {
select {
case <-s.done:
return s.closedError()
default:
return nil
}
}
// Close releases the connection and waits for owned goroutines; it is idempotent.
func (s *Session) Close() error {
s.fail(ErrClosed)
<-s.stopped
<-s.keepaliveStopped
return nil
}
func (s *Session) closedError() error {
s.mu.Lock()
defer s.mu.Unlock()
return transportError("session", s.failure)
}
func (s *Session) fail(err error) {
closeSocket := false
s.closeOnce.Do(func() {
s.mu.Lock()
s.failure = err
s.mu.Unlock()
close(s.done)
closeSocket = true
})
if closeSocket {
_ = s.conn.Close()
}
}
package rest
import (
"context"
"net/http"
"net/url"
"strconv"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// LoginContext identifies one account and its explicit regional settings.
type LoginContext struct {
Email string
ClientID string
Country string
CountryCode string
BaseURL string
}
// AuthContext holds caller-owned credentials for one account.
type AuthContext struct {
Token string
ClientID string
BaseURL string
RRiot dependencymodels.RRiot
}
// RegionRequest resolves an email at the supplied origin only.
type RegionRequest struct {
Email string
BaseURL string
}
// RegionResult contains the service's region and country discovery result.
type RegionResult struct{ Region dependencymodels.RegionData }
// CodeRequest requests an email verification code.
type CodeRequest struct{ Login LoginContext }
// CodeResult acknowledges acceptance of the email request.
type CodeResult struct{}
// SignRequest asks the service to sign a new 16-character mercy nonce.
type SignRequest struct{ Login LoginContext }
// SignResult contains the paired nonce and signature for one login.
type SignResult struct {
Nonce string
Key string
}
// AgreementRequest retrieves current agreement versions for a country.
type AgreementRequest struct{ Login LoginContext }
// AgreementResult supplies the agreement version used at login.
type AgreementResult struct{ Agreement dependencymodels.Agreement }
// LoginCodeRequest submits a code with caller-obtained mercy and agreement values.
type LoginCodeRequest struct {
Login LoginContext
Code string
Signature SignResult
Agreement dependencymodels.Agreement
}
// LoginPasswordRequest selects the evidenced legacy password endpoint.
type LoginPasswordRequest struct {
Login LoginContext
Password string
}
// LoginLegacyCodeRequest selects the evidenced legacy code endpoint.
type LoginLegacyCodeRequest struct {
Login LoginContext
Code string
}
// LoginResult holds the newly returned credentials without mutating the client.
type LoginResult struct{ User dependencymodels.UserData }
func loginHeaders(v LoginContext) http.Header {
return http.Header{
protocol.RESTHeaderHeaderClientid: {v.ClientID},
protocol.RESTHeaderHeaderClientlang: {string(dependencymodels.ClientLanguageEnglish)},
}
}
func validateLogin(v LoginContext) error {
if v.Email == "" || v.ClientID == "" {
return roborockerrors.New(roborockerrors.InvalidArgument, "login", "email and client ID required", nil)
}
return nil
}
// ResolveRegion makes one explicit regional lookup; it never cycles regions.
func (c *Client) ResolveRegion(ctx context.Context, request RegionRequest) (RegionResult, error) {
var out dependencymodels.RegionResponse
if request.Email == "" {
return RegionResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "region", "email required", nil)
}
query := url.Values{
protocol.RESTQueryEmail: {request.Email},
protocol.RESTQueryNeedtwostepauth: {string(dependencymodels.ResolveRegionParamsNeedtwostepauthFalse)},
}
err := c.exchange(ctx, request.BaseURL, protocol.RESTMethodResolveRegion,
protocol.RESTPathResolveRegion, query, nil, http.Header{}, &out)
if err == nil {
err = checkCode("region", out.Code)
}
if err == nil && (out.Data == nil || out.Data.Url == "") {
err = roborockerrors.New(roborockerrors.Protocol, "region", "missing region data", nil)
}
if err != nil {
return RegionResult{}, err
}
return RegionResult{Region: *out.Data}, nil
}
// RequestCode sends a v4 email code request without implicit fallback.
func (c *Client) RequestCode(ctx context.Context, request CodeRequest) (CodeResult, error) {
validationErr := validateLogin(request.Login)
if validationErr != nil {
return CodeResult{}, validationErr
}
var out dependencymodels.CodeResponse
form := url.Values{
protocol.RESTFormEmail: {request.Login.Email},
protocol.RESTFormType: {string(dependencymodels.Login)},
protocol.RESTFormPlatform: {""},
}
err := c.exchange(ctx, request.Login.BaseURL, protocol.RESTMethodRequestCode,
protocol.RESTPathRequestCode, nil, form, loginHeaders(request.Login), &out)
if err == nil {
err = checkCode("request_code", out.Code)
}
return CodeResult{}, err
}
// SignKey generates a nonce and obtains its matching mercy key.
func (c *Client) SignKey(ctx context.Context, request SignRequest) (SignResult, error) {
validationErr := validateLogin(request.Login)
if validationErr != nil {
return SignResult{}, validationErr
}
nonce, err := c.mercyNonce()
if err != nil {
return SignResult{}, err
}
var out dependencymodels.SignResponse
query := url.Values{protocol.RESTQueryS: {nonce}}
err = c.exchange(ctx, request.Login.BaseURL, protocol.RESTMethodSignKey,
protocol.RESTPathSignKey, query, nil, loginHeaders(request.Login), &out)
if err == nil {
err = checkCode("sign_key", out.Code)
}
if err == nil && (out.Data == nil || out.Data.K == "") {
err = roborockerrors.New(roborockerrors.Protocol, "sign_key", "missing signed key", nil)
}
if err != nil {
return SignResult{}, err
}
return SignResult{Nonce: nonce, Key: out.Data.K}, nil
}
// GetAgreement retrieves the latest agreement; it does not conceal failures.
func (c *Client) GetAgreement(ctx context.Context, request AgreementRequest) (AgreementResult, error) {
if request.Login.Country == "" {
return AgreementResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "agreement", "country required", nil)
}
var out dependencymodels.AgreementResponse
query := url.Values{protocol.RESTQueryCountry: {request.Login.Country}}
err := c.exchange(ctx, request.Login.BaseURL, protocol.RESTMethodGetAgreement,
protocol.RESTPathGetAgreement, query, nil, loginHeaders(request.Login), &out)
if err == nil {
err = checkCode("agreement", out.Code)
}
if err == nil && out.Data == nil {
err = roborockerrors.New(roborockerrors.Protocol, "agreement", "missing agreement", nil)
}
if err != nil {
return AgreementResult{}, err
}
return AgreementResult{Agreement: *out.Data}, nil
}
// LoginCode submits explicit country, signature, and agreement settings.
func (c *Client) LoginCode(ctx context.Context, request LoginCodeRequest) (LoginResult, error) {
err := validateLogin(request.Login)
if err != nil {
return LoginResult{}, err
}
if request.Code == "" || request.Login.Country == "" || request.Login.CountryCode == "" ||
!validMercyNonce(request.Signature.Nonce) || request.Signature.Key == "" ||
request.Agreement.MajorVersion < 0 || request.Agreement.MinorVersion < 0 {
return LoginResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "login_code",
"code, country, signature and agreement required", nil)
}
headers := loginHeaders(request.Login)
headers.Set(protocol.RESTHeaderXMercyKs, request.Signature.Nonce)
headers.Set(protocol.RESTHeaderXMercyK, request.Signature.Key)
headers.Set(protocol.RESTHeaderHeaderAppversion, string(dependencymodels.AppVersionIOS))
headers.Set(protocol.RESTHeaderHeaderPhonesystem, string(dependencymodels.PhoneSystemIOS))
headers.Set(protocol.RESTHeaderHeaderPhonemodel, string(dependencymodels.PhoneModelIPhone16))
form := url.Values{
protocol.RESTFormEmail: {request.Login.Email},
protocol.RESTFormCode: {request.Code},
protocol.RESTFormCountry: {request.Login.Country},
protocol.RESTFormCountryCode: {request.Login.CountryCode},
protocol.RESTFormMajorVersion: {strconv.FormatInt(request.Agreement.MajorVersion, 10)},
protocol.RESTFormMinorVersion: {strconv.FormatInt(request.Agreement.MinorVersion, 10)},
}
return c.login(ctx, request.Login, protocol.RESTMethodLoginCode, protocol.RESTPathLoginCode, nil, form, headers)
}
// LoginPassword uses the legacy v1 password exchange evidenced by the reference.
func (c *Client) LoginPassword(ctx context.Context, request LoginPasswordRequest) (LoginResult, error) {
err := validateLogin(request.Login)
if err != nil {
return LoginResult{}, err
}
if request.Password == "" {
return LoginResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "login_password", "password required", nil)
}
query := url.Values{
protocol.RESTQueryUsername: {request.Login.Email},
protocol.RESTQueryPassword: {request.Password},
protocol.RESTQueryNeedtwostepauth: {string(dependencymodels.LoginPasswordParamsNeedtwostepauthFalse)},
}
return c.login(ctx, request.Login, protocol.RESTMethodLoginPassword,
protocol.RESTPathLoginPassword, query, nil, loginHeaders(request.Login))
}
// LoginLegacyCode submits an explicitly selected v1 email code login.
func (c *Client) LoginLegacyCode(ctx context.Context, request LoginLegacyCodeRequest) (LoginResult, error) {
err := validateLogin(request.Login)
if err != nil {
return LoginResult{}, err
}
if request.Code == "" {
return LoginResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "legacy_code", "code required", nil)
}
query := url.Values{
protocol.RESTQueryUsername: {request.Login.Email},
protocol.RESTQueryVerifycode: {request.Code},
protocol.RESTQueryVerifycodetype: {string(dependencymodels.AUTHEMAILCODE)},
}
return c.login(ctx, request.Login, protocol.RESTMethodLoginLegacyCode,
protocol.RESTPathLoginLegacyCode, query, nil, loginHeaders(request.Login))
}
func (c *Client) login(
ctx context.Context, login LoginContext, method, path string, query, form url.Values, headers http.Header,
) (LoginResult, error) {
var out dependencymodels.LoginResponse
err := c.exchange(ctx, login.BaseURL, method, path, query, form, headers, &out)
if err == nil {
err = checkCode("login", out.Code)
}
if err == nil && (out.Data == nil || out.Data.Rriot.U == "" || out.Data.Rriot.S == "" ||
out.Data.Rriot.H == "" || out.Data.Rriot.K == "") {
err = roborockerrors.New(roborockerrors.Protocol, "login", "missing account credentials", nil)
}
if err != nil {
return LoginResult{}, err
}
return LoginResult{User: *out.Data}, nil
}
// Package rest implements stateless Roborock cloud authentication and inventory.
package rest
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
const defaultTimeout = 30 * time.Second
const maximumResponseBytes = 8 << 20
// HTTPDoer makes every REST exchange injectable.
type HTTPDoer interface {
Do(request *http.Request) (*http.Response, error)
}
// Client retains configuration only; credentials belong to each request.
type Client struct {
http HTTPDoer
baseURL string
clock func() time.Time
random io.Reader
}
// Option configures a reusable client at construction.
type Option func(*Client)
// WithHTTPClient supplies an HTTP transport owned by the caller.
func WithHTTPClient(v HTTPDoer) Option { return func(c *Client) { c.http = v } }
// WithBaseURL sets the default explicit regional origin.
func WithBaseURL(v string) Option { return func(c *Client) { c.baseURL = v } }
// WithClock supplies signing time.
func WithClock(v func() time.Time) Option { return func(c *Client) { c.clock = v } }
// WithRandom supplies signing entropy. The reader must support concurrent reads.
func WithRandom(v io.Reader) Option { return func(c *Client) { c.random = v } }
// New constructs a client; a regional origin is supplied here or per request.
func New(opts ...Option) (*Client, error) {
client := &Client{
http: &http.Client{Timeout: defaultTimeout, CheckRedirect: preventRedirect, Transport: nil, Jar: nil},
baseURL: "", clock: time.Now, random: rand.Reader,
}
for _, o := range opts {
if o != nil {
o(client)
}
}
if client.http == nil || client.clock == nil || client.random == nil {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, "rest.New", "nil client option", nil)
}
if client.baseURL != "" {
err := validateOrigin(client.baseURL)
if err != nil {
return nil, err
}
}
return client, nil
}
func preventRedirect(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
func validateOrigin(base string) error {
u, err := url.Parse(base)
if err != nil {
return roborockerrors.New(roborockerrors.InvalidArgument, "rest.origin", "expected an HTTP origin", err)
}
if !isHTTPOrigin(u) {
return roborockerrors.New(roborockerrors.InvalidArgument, "rest.origin", "expected an HTTP origin", nil)
}
return nil
}
func isHTTPOrigin(parsed *url.URL) bool {
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return false
}
return parsed.Hostname() != "" && parsed.User == nil && parsed.RawQuery == "" && !parsed.ForceQuery &&
parsed.Fragment == "" && (parsed.Path == "" || parsed.Path == "/")
}
func (c *Client) prepareRequest(
ctx context.Context, base, method, path string, query, form url.Values, headers http.Header,
) (*http.Request, error) {
if base == "" {
base = c.baseURL
}
validationErr := validateOrigin(base)
if validationErr != nil {
return nil, validationErr
}
endpoint := strings.TrimSuffix(base, "/") + path
if len(query) > 0 {
endpoint += "?" + query.Encode()
}
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, failure(path, err)
}
for key, values := range headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Set(protocol.RESTHeaderAccept, protocol.RESTMediaJSON)
if form != nil {
req.Header.Set(protocol.RESTHeaderContentType, protocol.RESTMediaForm)
}
return req, nil
}
func (c *Client) exchange(
ctx context.Context, base, method, path string, query, form url.Values, headers http.Header, result any,
) error {
req, err := c.prepareRequest(ctx, base, method, path, query, form, headers)
if err != nil {
return err
}
err = validateRoute(method, path)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
return failure(path, err)
}
if resp == nil || resp.Body == nil {
return roborockerrors.New(roborockerrors.Protocol, path, "missing HTTP response", nil)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return vendorError(path, resp.StatusCode)
}
return decodeResponse(resp.Body, method, path, result)
}
func decodeResponse(body io.Reader, method, path string, result any) error {
limited := io.LimitReader(body, maximumResponseBytes+1)
data, err := io.ReadAll(limited)
if err != nil {
return failure(path, err)
}
if len(data) > maximumResponseBytes {
return roborockerrors.New(roborockerrors.Protocol, path, "response exceeds size limit", nil)
}
err = validateResponse(method, path, data)
if err != nil {
return err
}
err = json.Unmarshal(data, result)
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, path, "invalid JSON response", err)
}
return nil
}
func failure(operation string, err error) error {
kind := roborockerrors.Unavailable
switch {
case errors.Is(err, context.Canceled):
kind = roborockerrors.Canceled
case errors.Is(err, context.DeadlineExceeded):
kind = roborockerrors.Timeout
default:
var timeout interface{ Timeout() bool }
if errors.As(err, &timeout) && timeout.Timeout() {
kind = roborockerrors.Timeout
}
}
return roborockerrors.New(kind, operation, "HTTP exchange failed", err)
}
func vendorError(operation string, code int) *roborockerrors.Error {
kind := roborockerrors.Protocol
switch code {
case http.StatusUnauthorized, http.StatusForbidden,
dependencymodels.VendorCodeInvalidCredentials, dependencymodels.VendorCodeInvalidCode,
dependencymodels.VendorCodeInvalidAgreement, dependencymodels.VendorCodeAgreementRequired:
kind = roborockerrors.Unauthorized
case http.StatusNotFound, dependencymodels.VendorCodeAccountMissing,
dependencymodels.VendorCodeWrongRegion, dependencymodels.VendorCodeDeletedAccount:
kind = roborockerrors.NotFound
case http.StatusTooManyRequests, dependencymodels.VendorCodeTooFrequent:
kind = roborockerrors.RateLimited
case dependencymodels.VendorCodeInvalidEmail, dependencymodels.VendorCodeMissingParameters:
kind = roborockerrors.InvalidArgument
}
if code >= http.StatusInternalServerError && code < 600 {
kind = roborockerrors.Unavailable
}
e := roborockerrors.New(kind, operation, "service rejected request", nil)
e.Code = code
return e
}
func checkCode(operation string, code int) error {
if code != dependencymodels.VendorCodeSuccess {
return vendorError(operation, code)
}
return nil
}
package rest
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// HomeDetailRequest authenticates a home ID lookup using the caller's token.
type HomeDetailRequest struct{ Auth AuthContext }
// HomeDetailResult contains the vendor home identifier.
type HomeDetailResult struct{ Home dependencymodels.HomeDetail }
// HomeDataRequest selects a home and a documented version (1, 2, or 3).
type HomeDataRequest struct {
Auth AuthContext
HomeID int64
Version int
}
// HomeDataResult contains inventory with absent fields preserved by pointers.
type HomeDataResult struct{ Home dependencymodels.HomeData }
// HomeDetail uses plain token authentication against the regional IoT origin.
func (c *Client) HomeDetail(ctx context.Context, request HomeDetailRequest) (HomeDetailResult, error) {
if request.Auth.Token == "" || request.Auth.ClientID == "" {
return HomeDetailResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "home_detail",
"token and client ID required", nil)
}
headers := http.Header{
protocol.RESTHeaderAuthorization: {request.Auth.Token},
protocol.RESTHeaderHeaderClientid: {request.Auth.ClientID},
}
var out dependencymodels.HomeDetailResponse
err := c.exchange(ctx, request.Auth.BaseURL, protocol.RESTMethodGetHomeDetail,
protocol.RESTPathGetHomeDetail, nil, nil, headers, &out)
if err == nil {
err = checkCode("home_detail", out.Code)
}
if err == nil && (out.Data == nil || out.Data.RrHomeId <= 0) {
err = roborockerrors.New(roborockerrors.Protocol, "home_detail", "missing home ID", nil)
}
if err != nil {
return HomeDetailResult{}, err
}
return HomeDetailResult{Home: *out.Data}, nil
}
// HomeData retrieves a specified home's inventory using per-call Hawk credentials.
func homeDataPath(homeID int64, version int) (string, error) {
var template string
switch version {
case int(dependencymodels.HomeDataVersionV1):
template = protocol.RESTPathGetHomeDatav1
case int(dependencymodels.HomeDataVersionV2):
template = protocol.RESTPathGetHomeDatav2
case int(dependencymodels.HomeDataVersionV3):
template = protocol.RESTPathGetHomeDatav3
default:
return "", roborockerrors.New(roborockerrors.InvalidArgument, "home_data", "version must be 1, 2, or 3", nil)
}
return strings.ReplaceAll(template, "{homeID}", strconv.FormatInt(homeID, 10)), nil
}
// HomeData retrieves a specified home's inventory using per-call Hawk credentials.
func (c *Client) HomeData(ctx context.Context, request HomeDataRequest) (HomeDataResult, error) {
if request.HomeID <= 0 || request.Auth.RRiot.R.A == nil || *request.Auth.RRiot.R.A == "" {
return HomeDataResult{}, roborockerrors.New(roborockerrors.InvalidArgument, "home_data",
"home ID and account API origin required", nil)
}
path, err := homeDataPath(request.HomeID, request.Version)
if err != nil {
return HomeDataResult{}, err
}
authorization, err := c.hawk(request.Auth.RRiot, path)
if err != nil {
return HomeDataResult{}, err
}
var out dependencymodels.HomeDataResponse
headers := http.Header{protocol.RESTHeaderAuthorization: {authorization}}
err = c.exchange(ctx, *request.Auth.RRiot.R.A, protocol.RESTMethodGetHomeDatav1, path, nil, nil, headers, &out)
if err != nil {
return HomeDataResult{}, err
}
err = checkInventory(out)
if err != nil {
return HomeDataResult{}, err
}
return HomeDataResult{Home: *out.Result}, nil
}
func checkInventory(out dependencymodels.HomeDataResponse) error {
if !out.Success {
code := 0
if out.Code != nil {
code = *out.Code
}
return vendorError("home_data", code)
}
if out.Result == nil {
return roborockerrors.New(roborockerrors.Protocol, "home_data", "missing home inventory", nil)
}
return nil
}
package rest
import (
"encoding/json"
"strings"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// DecodeProperty decodes JSON-encoded product constraints; JSON null returns nil.
func DecodeProperty(encoded string) (*dependencymodels.ProductPropertyConstraints, error) {
return decodeInventory[dependencymodels.ProductPropertyConstraints](encoded, "ProductPropertyConstraints")
}
// DecodeProductInfo decodes the JSON string in inventory datapoint 10005.
func DecodeProductInfo(encoded string) (*dependencymodels.InventoryProductInfo, error) {
return decodeInventory[dependencymodels.InventoryProductInfo](encoded, "InventoryProductInfo")
}
// DecodeOTA decodes the JSON string in inventory datapoint 10007.
func DecodeOTA(encoded string) (*dependencymodels.InventoryOTA, error) {
return decodeInventory[dependencymodels.InventoryOTA](encoded, "InventoryOTA")
}
// DecodeProgramState decodes the JSON string in inventory datapoint 10001.
func DecodeProgramState(encoded string) (*dependencymodels.InventoryProgramState, error) {
return decodeInventory[dependencymodels.InventoryProgramState](encoded, "InventoryProgramState")
}
func decodeInventory[T any](encoded, schemaName string) (*T, error) {
if strings.TrimSpace(encoded) == "null" {
return nil, nil
}
schemas, err := responseSchemas()
if err != nil {
return nil, roborockerrors.New(roborockerrors.Protocol, "inventory", "contract unavailable", err)
}
schema := schemas["schema:"+schemaName]
if schema == nil {
return nil, roborockerrors.New(roborockerrors.Protocol, "inventory", "missing embedded inventory contract", nil)
}
var value any
err = json.Unmarshal([]byte(encoded), &value)
if err != nil {
return nil, roborockerrors.New(roborockerrors.Protocol, "inventory", "invalid embedded JSON", err)
}
err = schema.VisitJSON(withoutNulls(value))
if err != nil {
return nil, roborockerrors.New(roborockerrors.Protocol, "inventory", "embedded JSON violates contract", err)
}
var result T
err = json.Unmarshal([]byte(encoded), &result)
if err != nil {
return nil, roborockerrors.New(roborockerrors.Protocol, "inventory", "invalid embedded inventory value", err)
}
return &result, nil
}
package rest
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// HomeRoomsRequest refreshes rooms using a caller-owned home ID and account.
type HomeRoomsRequest struct {
Auth AuthContext
HomeID int64
}
// SharedDeviceRoomsRequest selects a received device without changing account state.
type SharedDeviceRoomsRequest struct {
Auth AuthContext
DeviceID string
}
// RoomsResult preserves missing room names and forward-compatible room fields.
type RoomsResult struct {
Rooms []dependencymodels.RefreshedRoom
}
// HomeRooms reads the rooms currently associated with a home.
func (c *Client) HomeRooms(ctx context.Context, request HomeRoomsRequest) (RoomsResult, error) {
if request.HomeID <= 0 {
return RoomsResult{}, roborockerrors.New(
roborockerrors.InvalidArgument, "home_rooms", "positive home ID required", nil,
)
}
path := strings.ReplaceAll(
protocol.RESTPathGetHomeRooms, "{"+protocol.RESTPathHomeID+"}", strconv.FormatInt(request.HomeID, 10),
)
var out dependencymodels.HomeRoomsResponse
err := c.roomsExchange(ctx, request.Auth, protocol.RESTMethodGetHomeRooms, path, &out)
if err != nil {
return RoomsResult{}, err
}
err = checkRooms(out.Success, out.Code, out.Result != nil)
if err != nil {
return RoomsResult{}, err
}
return RoomsResult{Rooms: *out.Result}, nil
}
// SharedDeviceRooms accepts the provider's roomId alias when id is absent.
func (c *Client) SharedDeviceRooms(ctx context.Context, request SharedDeviceRoomsRequest) (RoomsResult, error) {
if request.DeviceID == "" {
return RoomsResult{}, roborockerrors.New(
roborockerrors.InvalidArgument, "shared_device_rooms", "device ID required", nil,
)
}
path := strings.ReplaceAll(
protocol.RESTPathGetSharedDeviceRooms, "{"+protocol.RESTPathDeviceID+"}", url.PathEscape(request.DeviceID),
)
var out dependencymodels.SharedDeviceRoomsResponse
err := c.roomsExchange(ctx, request.Auth, protocol.RESTMethodGetSharedDeviceRooms, path, &out)
if err != nil {
return RoomsResult{}, err
}
err = checkRooms(out.Success, out.Code, out.Result != nil)
if err != nil {
return RoomsResult{}, err
}
result := RoomsResult{Rooms: make([]dependencymodels.RefreshedRoom, 0, len(*out.Result))}
for _, room := range *out.Result {
roomID := room.Id
if roomID == nil {
roomID = room.RoomId
}
result.Rooms = append(result.Rooms, dependencymodels.RefreshedRoom{
Id: *roomID, Name: room.Name, AdditionalProperties: room.AdditionalProperties,
})
}
return result, nil
}
func (c *Client) roomsExchange(ctx context.Context, auth AuthContext, method, path string, out any) error {
if auth.RRiot.R.A == nil || *auth.RRiot.R.A == "" {
return roborockerrors.New(roborockerrors.InvalidArgument, "rooms", "account API origin required", nil)
}
authorization, err := c.hawk(auth.RRiot, path)
if err != nil {
return err
}
headers := http.Header{protocol.RESTHeaderAuthorization: {authorization}}
return c.exchange(ctx, *auth.RRiot.R.A, method, path, nil, nil, headers, out)
}
func checkRooms(success bool, code *int, present bool) error {
if !success {
value := 0
if code != nil {
value = *code
}
return vendorError("rooms", value)
}
if !present {
return roborockerrors.New(roborockerrors.Protocol, "rooms", "missing room list", nil)
}
return nil
}
package rest
import (
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"math/big"
"strconv"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
func (c *Client) mercyNonce() (string, error) {
nonceBytes := make([]byte, protocol.RESTMercyNonceLength)
limit := big.NewInt(int64(len(protocol.RESTMercyCharacters)))
for index := range nonceBytes {
n, err := rand.Int(c.random, limit)
if err != nil {
return "", roborockerrors.New(roborockerrors.Unavailable, "sign_key", "entropy unavailable", err)
}
nonceBytes[index] = protocol.RESTMercyCharacters[n.Int64()]
}
return string(nonceBytes), nil
}
func validMercyNonce(s string) bool {
if len(s) != protocol.RESTMercyNonceLength {
return false
}
for _, character := range s {
if !strings.ContainsRune(protocol.RESTMercyCharacters, character) {
return false
}
}
return true
}
func (c *Client) hawk(request dependencymodels.RRiot, path string) (string, error) {
if request.U == "" || request.S == "" || request.H == "" || strings.ContainsAny(request.U+request.S, "\"\r\n\\") {
return "", roborockerrors.New(roborockerrors.InvalidArgument, "hawk", "invalid Hawk credentials", nil)
}
entropy := make([]byte, protocol.RESTHawkEntropyBytes)
_, err := io.ReadFull(c.random, entropy)
if err != nil {
return "", roborockerrors.New(roborockerrors.Unavailable, "hawk", "entropy unavailable", err)
}
nonce := base64.RawURLEncoding.EncodeToString(entropy)
timestamp := strconv.FormatInt(c.clock().Unix(), 10)
digest := md5.Sum([]byte(path))
input := fmt.Sprintf(protocol.RESTHawkMACInputFormat,
request.U, request.S, nonce, timestamp, hex.EncodeToString(digest[:]))
mac := hmac.New(sha256.New, []byte(request.H))
_, _ = mac.Write([]byte(input))
return fmt.Sprintf(protocol.RESTHawkHeaderFormat,
request.U, request.S, timestamp, nonce, base64.StdEncoding.EncodeToString(mac.Sum(nil))), nil
}
package rest
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/getkin/kin-openapi/openapi3"
"github.com/portpowered/go-roborock/api"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
var responseSchemas = sync.OnceValues(loadResponseSchemas)
func loadResponseSchemas() (map[string]*openapi3.Schema, error) {
result := make(map[string]*openapi3.Schema)
for _, file := range []string{"auth.openapi.yaml", "devices.openapi.yaml"} {
data, err := api.RESTContracts.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read response contract: %w", err)
}
doc, err := openapi3.NewLoader().LoadFromData(data)
if err != nil {
return nil, fmt.Errorf("load response contract: %w", err)
}
for name, component := range doc.Components.Schemas {
result["schema:"+name] = component.Value
}
err = collectResponses(doc, result)
if err != nil {
return nil, err
}
}
return result, nil
}
func collectResponses(doc *openapi3.T, result map[string]*openapi3.Schema) error {
for path, item := range doc.Paths.Map() {
for method, operation := range item.Operations() {
response := operation.Responses.Status(http.StatusOK)
if response == nil || response.Value == nil {
return roborockerrors.New(roborockerrors.Protocol, "contract",
"missing successful response contract", nil)
}
media := response.Value.Content.Get(protocol.RESTMediaJSON)
if media == nil || media.Schema == nil {
return roborockerrors.New(roborockerrors.Protocol, "contract", "missing JSON response contract", nil)
}
result[method+" "+path] = media.Schema.Value
}
}
return nil
}
func validateResponse(method, path string, data []byte) error {
schemas, err := responseSchemas()
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, path, "response contract unavailable", err)
}
schema := findResponseSchema(schemas, method+" "+path)
if schema == nil {
return roborockerrors.New(roborockerrors.Unsupported, path, "route absent from response contract", nil)
}
var value any
err = json.Unmarshal(data, &value)
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, path, "invalid JSON response", err)
}
err = schema.VisitJSON(withoutNulls(value))
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, path, "response violates contract", err)
}
return nil
}
// withoutNulls treats a null object member as absent before contract validation,
// so the service may null any field without failing a call. Required fields still fail when
// missing or null, and unknown fields remain accepted (SCHEMA-07).
func withoutNulls(value any) any {
switch typed := value.(type) {
case map[string]any:
out := make(map[string]any, len(typed))
for key, member := range typed {
if member != nil {
out[key] = withoutNulls(member)
}
}
return out
case []any:
// Array positions are kept: a null element would decode as an empty item.
out := make([]any, len(typed))
for index, element := range typed {
out[index] = withoutNulls(element)
}
return out
default:
return value
}
}
func validateRoute(method, path string) error {
schemas, err := responseSchemas()
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, path, "response contract unavailable", err)
}
if findResponseSchema(schemas, method+" "+path) == nil {
return roborockerrors.New(roborockerrors.Unsupported, path, "route absent from response contract", nil)
}
return nil
}
func findResponseSchema(schemas map[string]*openapi3.Schema, key string) *openapi3.Schema {
if schema := schemas[key]; schema != nil {
return schema
}
for route, candidate := range schemas {
if matchesRoute(route, key) {
return candidate
}
}
return nil
}
func matchesRoute(template, key string) bool {
expected := strings.Split(template, "/")
actual := strings.Split(key, "/")
if len(expected) != len(actual) {
return false
}
for index, segment := range expected {
if strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") {
if actual[index] == "" {
return false
}
continue
}
if segment != actual[index] {
return false
}
}
return true
}
package roborock
import (
"context"
"encoding/json"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// A01Operations lists typed controls for A01 wet cleaners and washing machines.
// Select query properties supported by the appliance's discovered product schema.
// A successful write reports MQTT publication, not device acceptance or cycle completion.
type A01Operations interface {
GetDyadState(ctx context.Context, request GetDyadStateRequest) (DyadState, error)
GetZeoState(ctx context.Context, request GetZeoStateRequest) (ZeoState, error)
SetDyadSettings(ctx context.Context, request SetDyadSettingsRequest) error
SetZeoSettings(ctx context.Context, request SetZeoSettingsRequest) error
StartZeo(ctx context.Context, request StartZeoRequest) error
}
// GetDyadState queries exactly the selected appliance-supported datapoints.
// Unselected fields remain nil. Unknown numeric enum values are retained.
func (s *DeviceSession) GetDyadState(ctx context.Context, request GetDyadStateRequest) (DyadState, error) {
var state DyadState
err := queryA01State[DyadState, dependencymodels.DyadStateWire](
ctx, s, request.Properties, "get dyad state", &state,
)
return state, err
}
// GetZeoState queries exactly the selected appliance-supported datapoints.
// A deadline prevents waiting indefinitely for a property unsupported by the appliance.
func (s *DeviceSession) GetZeoState(ctx context.Context, request GetZeoStateRequest) (ZeoState, error) {
var state ZeoState
err := queryA01State[ZeoState, dependencymodels.ZeoStateWire](
ctx, s, request.Properties, "get zeo state", &state,
)
return state, err
}
// SetDyadSettings publishes only the supplied settings; it never retries the command.
func (s *DeviceSession) SetDyadSettings(ctx context.Context, request SetDyadSettingsRequest) error {
if !validA01Time(request.SilentModeStartTime) || !validA01Time(request.SilentModeEndTime) ||
(request.Volume != nil && (*request.Volume < 0 || *request.Volume > 100)) {
return roborockerrors.New(roborockerrors.InvalidArgument, "set dyad settings", "invalid volume or minute of day", nil)
}
return setA01Settings[SetDyadSettingsRequest, dependencymodels.SetDyadSettingsWire](
ctx, s, request, "set dyad settings",
)
}
// SetZeoSettings publishes only independent settings supplied by the caller.
// Cycle parameters belong in StartZeo and must be bundled with the start signal.
func (s *DeviceSession) SetZeoSettings(ctx context.Context, request SetZeoSettingsRequest) error {
if !validA01Time(request.SilentModeStartTime) || !validA01Time(request.SilentModeEndTime) {
return roborockerrors.New(roborockerrors.InvalidArgument, "set zeo settings", "invalid minute of day", nil)
}
return setA01Settings[SetZeoSettingsRequest, dependencymodels.SetZeoSettingsWire](ctx, s, request, "set zeo settings")
}
// StartZeo bundles the start signal with the required mode and program and optional cycle parameters.
// Success reports publication; observe GetZeoState for physical cycle progress.
func (s *DeviceSession) StartZeo(ctx context.Context, request StartZeoRequest) error {
const operation = "start zeo"
err := s.requireA01(operation)
if err != nil {
return err
}
if request.Mode == ZeoModeNull || request.Program == ZeoProgramNull {
return roborockerrors.New(roborockerrors.InvalidArgument, operation, "mode and program must be supplied", nil)
}
values, err := encodeA01[StartZeoRequest, dependencymodels.StartZeoWire](request)
if err != nil {
return roborockerrors.New(roborockerrors.InvalidArgument, operation, "invalid cycle parameters", err)
}
var start dependencymodels.StartZeoWire
start.Start = protocol.A01ZeoStartEnabled
encodedStart, err := json.Marshal(start.Start)
if err != nil {
return roborockerrors.New(roborockerrors.InvalidArgument, operation, "invalid start signal", err)
}
values[int(ZeoPropertyStart)] = encodedStart
return publishA01(ctx, s, values, operation)
}
func (s *DeviceSession) requireA01(operation string) error {
if s == nil || s.rpc == nil {
return roborockerrors.New(roborockerrors.Closed, operation, "device session is unavailable", nil)
}
if s.protocol != string(ProtocolA01) {
return roborockerrors.New(roborockerrors.Unsupported, operation, "device does not use the A01 protocol", nil)
}
return nil
}
var _ A01Operations = (*DeviceSession)(nil)
func validA01Time(value *int) bool {
const minutesPerDay = 24 * 60
return value == nil || (*value >= 0 && *value < minutesPerDay)
}
package roborock
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
var (
errA01NoProperties = errors.New("at least one property is required")
errA01InvalidProperty = errors.New("unknown, unsupported, or repeated property")
errA01NullProperty = errors.New("appliance property cannot be null")
)
// a01Fields derives numeric keys from generated wire models and semantic keys
// from generated public projections. The shared Go field name joins the models.
// Contract tests keep this mapping aligned with both checked-in schemas.
func a01Fields[Projection, Wire any]() map[int]string {
projection := reflect.TypeFor[Projection]()
wire := reflect.TypeFor[Wire]()
fields := make(map[int]string, wire.NumField())
for i := range wire.NumField() {
field := wire.Field(i)
publicField, exists := projection.FieldByName(field.Name)
if !exists {
continue
}
wireKey, _, _ := strings.Cut(field.Tag.Get("json"), ",")
propertyID, err := strconv.Atoi(wireKey)
if err == nil {
publicKey, _, _ := strings.Cut(publicField.Tag.Get("json"), ",")
fields[propertyID] = publicKey
}
}
return fields
}
func queryA01State[Projection, Wire any, Property ~int](
ctx context.Context, session *DeviceSession, properties []Property, operation string, state *Projection,
) error {
err := session.requireA01(operation)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
fields := a01Fields[Projection, Wire]()
ids, err := validateA01Properties(properties, fields)
if err != nil {
return roborockerrors.New(roborockerrors.InvalidArgument, operation, "invalid property selection", err)
}
values, err := session.rpc.QueryA01(ctx, ids)
if err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
// Decode only selected values: unsolicited data does not change this request's result.
selected := make(map[int]json.RawMessage, len(ids))
for _, propertyID := range ids {
value, exists := values[propertyID]
if !exists {
return roborockerrors.New(roborockerrors.Protocol, operation, "requested property is missing", nil)
}
selected[propertyID] = value
}
err = decodeA01[Projection, Wire](selected, state)
if err != nil {
return roborockerrors.New(roborockerrors.Protocol, operation, "invalid appliance property value", err)
}
return nil
}
func validateA01Properties[Property ~int](properties []Property, fields map[int]string) ([]int, error) {
if len(properties) == 0 {
return nil, errA01NoProperties
}
ids := make([]int, len(properties))
seen := make(map[int]bool, len(properties))
for position, property := range properties {
propertyID := int(property)
if _, known := fields[propertyID]; !known || seen[propertyID] {
return nil, errA01InvalidProperty
}
ids[position], seen[propertyID] = propertyID, true
}
return ids, nil
}
func decodeA01[Projection, Wire any](values map[int]json.RawMessage, state *Projection) error {
fields := a01Fields[Projection, Wire]()
semantic := make(map[string]json.RawMessage, len(values))
wireValues := make(map[string]json.RawMessage, len(values))
for propertyID, value := range values {
if string(value) == "null" || strings.TrimSpace(string(value)) == "null" {
return errA01NullProperty
}
wireValues[strconv.Itoa(propertyID)] = value
semantic[fields[propertyID]] = value
}
// Validate wire types before adaptation, including encoded string-valued fields.
body, err := json.Marshal(wireValues)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
var wire Wire
err = json.Unmarshal(body, &wire)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
err = adaptA01Durations[Projection](semantic)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
body, err = json.Marshal(semantic)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
err = json.Unmarshal(body, state)
if err != nil {
return fmt.Errorf("decode A01 state: %w", err)
}
return nil
}
func adaptA01Durations[Projection any](semantic map[string]json.RawMessage) error {
// This field is the sole string-encoded scalar projection in the pinned Dyad contract.
field, exists := reflect.TypeFor[Projection]().FieldByName("RecentRunTime")
if !exists {
return nil
}
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
raw, present := semantic[key]
if !present {
return nil
}
var encoded string
err := json.Unmarshal(raw, &encoded)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
durations := make([]int, 0)
if encoded != "" {
for token := range strings.SplitSeq(encoded, ",") {
minutes, err := strconv.Atoi(token)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
durations = append(durations, minutes)
}
}
result, err := json.Marshal(durations)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
semantic[key] = result
return nil
}
func encodeA01[Projection, Wire any](request Projection) (map[int]json.RawMessage, error) {
body, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("encode A01 settings: %w", err)
}
var semantic map[string]json.RawMessage
err = json.Unmarshal(body, &semantic)
if err != nil {
return nil, fmt.Errorf("encode A01 settings: %w", err)
}
values := make(map[int]json.RawMessage, len(semantic))
for propertyID, key := range a01Fields[Projection, Wire]() {
if value, present := semantic[key]; present {
values[propertyID] = value
}
}
return values, nil
}
func setA01Settings[Projection, Wire any](
ctx context.Context, session *DeviceSession, request Projection, operation string,
) error {
err := session.requireA01(operation)
if err != nil {
return fmt.Errorf("adapt A01 property: %w", err)
}
values, err := encodeA01[Projection, Wire](request)
if err != nil || len(values) == 0 {
return roborockerrors.New(roborockerrors.InvalidArgument, operation, "at least one setting is required", err)
}
return publishA01(ctx, session, values, operation)
}
func publishA01(ctx context.Context, session *DeviceSession, values map[int]json.RawMessage, operation string) error {
err := session.rpc.SetA01(ctx, values)
if err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
package roborock
import (
"context"
"strconv"
"github.com/portpowered/go-roborock/pkg/dependencies/rest"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// ResolveLogin resolves an email at the explicitly configured regional origin.
// It returns country settings and origin without saving account state.
func (c *Client) ResolveLogin(ctx context.Context, request ResolveLoginRequest) (LoginContext, error) {
if request.ClientID == "" {
return LoginContext{}, roborockerrors.New(roborockerrors.InvalidArgument, "resolve_login", "client ID required", nil)
}
out, err := c.rest.ResolveRegion(ctx, rest.RegionRequest{Email: request.Email,
BaseURL: ""})
if err != nil {
return LoginContext{}, roborockerrors.Wrap(roborockerrors.Protocol, "resolve_login", "region lookup failed", err)
}
countryCode := ""
if out.Region.Countrycode != nil {
value, e := out.Region.Countrycode.AsRegionDataCountrycode0()
if e == nil {
countryCode = value
} else {
numeric, e := out.Region.Countrycode.AsRegionDataCountrycode1()
if e != nil {
return LoginContext{}, roborockerrors.New(
roborockerrors.Protocol, "resolve_login", "invalid country calling code", e,
)
}
countryCode = strconv.FormatInt(numeric, 10)
}
}
return LoginContext{Email: request.Email,
ClientID: request.ClientID,
BaseURL: &out.Region.Url,
Country: valueOrZero(out.Region.Country),
CountryCode: countryCode,
}, nil
}
// RequestLoginCode reports acknowledgement of the code email request.
func (c *Client) RequestLoginCode(ctx context.Context, request LoginCodeRequest) (LoginCodeResult, error) {
_, err := c.rest.RequestCode(ctx, rest.CodeRequest{Login: loginWire(request.Login)})
if err != nil {
return LoginCodeResult{Accepted: false}, roborockerrors.Wrap(
roborockerrors.Protocol, "request_code", "code request failed", err,
)
}
return LoginCodeResult{Accepted: true}, nil
}
// LoginWithCode obtains a fresh mercy signature and current agreement before exchanging the code.
// Returned credentials replace caller-owned storage; the client retains no token.
func (c *Client) LoginWithCode(ctx context.Context, request LoginWithCodeRequest) (LoginResult, error) {
if request.Code == "" || request.Login.Country == "" || request.Login.CountryCode == "" {
return LoginResult{}, roborockerrors.New(
roborockerrors.InvalidArgument, "login_code", "code and country settings required", nil,
)
}
login := loginWire(request.Login)
sig, err := c.rest.SignKey(ctx, rest.SignRequest{Login: login})
if err != nil {
return LoginResult{}, roborockerrors.Wrap(roborockerrors.Protocol, "login", "login exchange failed", err)
}
agreement, err := c.rest.GetAgreement(ctx, rest.AgreementRequest{Login: login})
if err != nil {
return LoginResult{}, roborockerrors.Wrap(roborockerrors.Protocol, "login", "login exchange failed", err)
}
out, err := c.rest.LoginCode(ctx, rest.LoginCodeRequest{Login: login,
Code: request.Code,
Signature: sig,
Agreement: agreement.Agreement,
})
if err != nil {
return LoginResult{}, roborockerrors.Wrap(roborockerrors.Protocol, "login", "login exchange failed", err)
}
return c.loginProjection(request.Login, out.User), nil
}
// LoginWithPassword explicitly selects the legacy password endpoint.
func (c *Client) LoginWithPassword(ctx context.Context, request LoginWithPasswordRequest) (LoginResult, error) {
out, err := c.rest.LoginPassword(ctx, rest.LoginPasswordRequest{Login: loginWire(request.Login),
Password: request.Password})
if err != nil {
return LoginResult{}, roborockerrors.Wrap(roborockerrors.Protocol, "login", "login exchange failed", err)
}
return c.loginProjection(request.Login, out.User), nil
}
func loginWire(l LoginContext) rest.LoginContext {
return rest.LoginContext{Email: l.Email,
ClientID: l.ClientID,
Country: l.Country,
CountryCode: l.CountryCode,
BaseURL: valueOrZero(l.BaseURL),
}
}
func (c *Client) loginProjection(login LoginContext, user dependencymodels.UserData) LoginResult {
baseURL := valueOrZero(login.BaseURL)
if baseURL == "" {
baseURL = c.baseURL
}
return LoginResult{Nickname: valueOrZero(user.Nickname),
UserID: valueOrZero(user.Uid),
Auth: AuthContext{Token: valueOrZero(user.Token),
ClientID: login.ClientID,
BaseURL: baseURL,
Mqtt: MQTTAuth{User: user.Rriot.U,
Secret: user.Rriot.S,
Key: user.Rriot.K,
SigningKey: user.Rriot.H,
APIURL: valueOrZero(user.Rriot.R.A),
BrokerURL: valueOrZero(user.Rriot.R.M),
},
},
}
}
func authWire(auth AuthContext) rest.AuthContext {
return rest.AuthContext{Token: auth.Token,
ClientID: auth.ClientID,
BaseURL: auth.BaseURL,
RRiot: dependencymodels.RRiot{U: auth.Mqtt.User,
S: auth.Mqtt.Secret,
K: auth.Mqtt.Key,
H: auth.Mqtt.SigningKey,
R: dependencymodels.RRiotReference{A: &auth.Mqtt.APIURL,
M: &auth.Mqtt.BrokerURL,
L: nil, R: nil, AdditionalProperties: nil,
},
AdditionalProperties: nil,
},
}
}
func valueOrZero[T any](p *T) T {
if p != nil {
return *p
}
var zero T
return zero
}
package roborock
import (
"context"
"encoding/base64"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
const (
cameraSDPPollInterval = time.Second
cameraSDPPollAttempts = 5
cameraCleanupTimeout = 3 * time.Second
)
// CameraSession owns preview signaling and its lifetime. The caller owns the
// WebRTC peer connection, rendering, and adding the returned SDP and ICE.
type CameraSession struct {
device *DeviceSession
description CameraDescription
life context.Context
cancel context.CancelFunc
closeOnce sync.Once
closeErr error
preview atomic.Bool
setupDone chan struct{}
}
// OpenCamera authenticates the pattern, starts preview, retrieves TURN, and
// exchanges SDP. Cancellation or any failure after preview starts stops preview.
func (s *DeviceSession) OpenCamera(ctx context.Context, req OpenCameraRequest) (*CameraSession, error) {
if s.protocol != string(ProtocolV1) {
return nil, unsupportedMap("OpenCamera")
}
camera, stopParent, err := s.reserveCamera(ctx, req)
if err != nil {
return nil, err
}
success := false
defer func() {
close(camera.setupDone)
if !success {
_ = camera.Close()
stopParent()
}
}()
err = camera.establish(req)
if err != nil {
return nil, err
}
success = true
go func() { <-camera.life.Done(); _ = camera.Close(); stopParent() }()
return camera, nil
}
func (s *DeviceSession) reserveCamera(ctx context.Context, req OpenCameraRequest) (*CameraSession, func() bool, error) {
if req.PatternPassword == "" || req.SdpOffer == "" {
return nil, nil, roborockerrors.New(
roborockerrors.InvalidArgument, "OpenCamera", "pattern password and SDP offer required", nil,
)
}
camera := new(CameraSession)
camera.device = s
camera.life, camera.cancel = context.WithCancel(ctx)
camera.setupDone = make(chan struct{})
s.mu.Lock()
if s.closing {
s.mu.Unlock()
camera.cancel()
return nil, nil, roborockerrors.New(roborockerrors.Closed, "OpenCamera", "device session is closing", nil)
}
if s.camera != nil {
s.mu.Unlock()
camera.cancel()
return nil, nil, roborockerrors.New(
roborockerrors.Backpressure,
"OpenCamera",
"camera preview already owned",
nil,
)
}
s.camera = camera
s.mu.Unlock()
stopParent := func() bool { return false }
if s.life != nil {
stopParent = context.AfterFunc(s.life, camera.cancel)
}
return camera, stopParent, nil
}
// Description returns the negotiated SDP answer and TURN credentials.
// Keep the TURN credential private and pass it only to the caller's WebRTC stack.
func (c *CameraSession) Description() CameraDescription { return c.description }
// Done is closed when camera signaling terminates or its owning device closes.
func (c *CameraSession) Done() <-chan struct{} { return c.life.Done() }
// Close cancels pending signaling and stops preview once with bounded cleanup.
func (c *CameraSession) Close() error {
c.closeOnce.Do(func() {
c.cancel()
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.life), cameraCleanupTimeout)
defer cancel()
select {
case <-c.setupDone:
if c.preview.Load() {
c.closeErr = c.ack(ctx, dependencymodels.RPCCameraStopPreview, dependencymodels.CameraEmptyParameters{})
}
case <-ctx.Done():
c.closeErr = operationError("CloseCamera", ctx.Err())
}
c.device.mu.Lock()
if c.device.camera == c {
c.device.camera = nil
}
c.device.mu.Unlock()
})
return c.closeErr
}
// SendICE sends one caller-provided plain ICE candidate without retries.
func (c *CameraSession) SendICE(ctx context.Context, req SendICERequest) (CommandAcknowledgement, error) {
if req.Candidate == "" {
return CommandAcknowledgement{}, roborockerrors.New(
roborockerrors.InvalidArgument,
"SendICE",
"candidate required",
nil,
)
}
callCtx, cancel, err := c.context(ctx)
if err != nil {
return CommandAcknowledgement{}, err
}
defer cancel()
err = c.ack(
callCtx,
dependencymodels.RPCCameraSendICE,
dependencymodels.CameraICEParameters{Ice: base64.StdEncoding.EncodeToString([]byte(req.Candidate))},
)
return CommandAcknowledgement{Acknowledged: err == nil, Sent: err == nil}, err
}
// GetICE reads and decodes the device's next ICE candidate.
func (c *CameraSession) GetICE(ctx context.Context, _ EmptyRequest) (DeviceICEResult, error) {
callCtx, cancel, err := c.context(ctx)
if err != nil {
return DeviceICEResult{}, err
}
defer cancel()
raw, err := c.call(callCtx, dependencymodels.RPCCameraGetICE, dependencymodels.CameraEmptyParameters{})
if err != nil {
return DeviceICEResult{}, err
}
var wire dependencymodels.CameraDeviceICE
err = json.Unmarshal(raw, &wire)
if err != nil {
return DeviceICEResult{}, cameraProtocol("GetICE", err)
}
encoded := firstString(wire.Ice, wire.DevIce)
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(decoded) == 0 {
return DeviceICEResult{}, cameraProtocol("GetICE", err)
}
return DeviceICEResult{Candidate: string(decoded)}, nil
}
func (c *CameraSession) establish(req OpenCameraRequest) error {
password := normalizePatternPassword(req.PatternPassword)
passwordParams := dependencymodels.CameraPasswordParameters{Password: password}
err := c.ack(c.life, dependencymodels.RPCCameraCheckPassword, passwordParams)
if err != nil {
return err
}
quality := CameraQualityHD
if req.Quality != nil {
quality = *req.Quality
}
previewParams := dependencymodels.CameraPreviewParameters{Password: password, Quality: string(quality)}
// Mark ownership before dispatch: uncertain acknowledgement still needs cleanup.
c.preview.Store(true)
err = c.ack(c.life, dependencymodels.RPCCameraStartPreview, previewParams)
if err != nil {
return err
}
turn, err := c.turn(c.life)
if err != nil {
return err
}
err = c.sendOffer(req.SdpOffer)
if err != nil {
return err
}
answer, err := c.waitSDP(c.life)
if err != nil {
return err
}
c.description = CameraDescription{SdpAnswer: answer, TurnServer: turn}
return nil
}
func (c *CameraSession) sendOffer(sdp string) error {
offer, err := json.Marshal(dependencymodels.CameraSDPEnvelope{Type: dependencymodels.CameraSDPTypeOffer, Sdp: sdp})
if err != nil {
return roborockerrors.New(roborockerrors.InvalidArgument, "OpenCamera", "cannot encode SDP", err)
}
params := dependencymodels.CameraSDPParameters{AppSdp: base64.StdEncoding.EncodeToString(offer)}
return c.ack(c.life, dependencymodels.RPCCameraSendSDP, params)
}
func (c *CameraSession) context(ctx context.Context) (context.Context, context.CancelFunc, error) {
if c.life.Err() != nil {
return nil, nil, roborockerrors.New(roborockerrors.Closed, "camera", "camera session is closed", nil)
}
merged, cancel := context.WithCancel(ctx)
stop := context.AfterFunc(c.life, cancel)
return merged, func() { stop(); cancel() }, nil
}
func (c *CameraSession) call(
ctx context.Context,
method dependencymodels.CameraRPCMethod,
params any,
) (json.RawMessage, error) {
return c.device.callV1(ctx, dependencymodels.RPCMethod(method), params)
}
func (c *CameraSession) ack(ctx context.Context, method dependencymodels.CameraRPCMethod, params any) error {
_, err := c.device.command(ctx, dependencymodels.RPCMethod(method), params)
return err
}
func cameraProtocol(operation string, cause error) error {
return roborockerrors.New(roborockerrors.Protocol, operation, "invalid camera signaling result", cause)
}
func (c *CameraSession) turn(ctx context.Context) (TURNServer, error) {
raw, err := c.call(ctx, dependencymodels.RPCCameraGetTURN, dependencymodels.CameraEmptyParameters{})
if err != nil {
return TURNServer{}, err
}
var wire dependencymodels.CameraTURN
err = json.Unmarshal(raw, &wire)
if err != nil {
return TURNServer{}, cameraProtocol("GetTURN", err)
}
result := TURNServer{
Url: valueOrZero(wire.Url),
Username: firstString(wire.Username, wire.User),
Credential: firstString(wire.Credential, wire.Pwd),
}
if result.Url == "" || result.Username == "" || result.Credential == "" {
return TURNServer{}, cameraProtocol("GetTURN", errResultShape)
}
return result, nil
}
func (c *CameraSession) waitSDP(ctx context.Context) (string, error) {
for attempt := range cameraSDPPollAttempts {
raw, err := c.call(ctx, dependencymodels.RPCCameraGetSDP, dependencymodels.CameraEmptyParameters{})
if err != nil {
return "", err
}
answer, retry, err := decodeCameraSDP(raw)
if err != nil {
return "", cameraProtocol("GetSDP", err)
}
if !retry {
return answer, nil
}
if attempt == cameraSDPPollAttempts-1 {
break
}
timer := time.NewTimer(cameraSDPPollInterval)
select {
case <-ctx.Done():
timer.Stop()
return "", operationError("GetSDP", ctx.Err())
case <-timer.C:
}
}
return "", roborockerrors.New(roborockerrors.Timeout, "GetSDP", "device SDP polling exhausted", nil)
}
package roborock
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
)
func normalizePatternPassword(password string) string {
compact := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(password), "-", ""), " ", "")
if len(compact) == md5.Size*2 {
_, err := hex.DecodeString(compact)
if err == nil {
return strings.ToLower(compact)
}
}
digest := md5.Sum([]byte(compact))
return hex.EncodeToString(digest[:])
}
func firstString(values ...*string) string {
for _, value := range values {
if value != nil && *value != "" {
return *value
}
}
return ""
}
func decodeCameraSDP(raw json.RawMessage) (string, bool, error) {
var wire dependencymodels.CameraDeviceSDP
err := json.Unmarshal(raw, &wire)
if err != nil {
return "", false, fmt.Errorf("decode camera SDP: %w", err)
}
if wire.Sdp != nil && *wire.Sdp != "" {
return *wire.Sdp, false, nil
}
if wire.DevSdp == nil || *wire.DevSdp == "" {
return "", false, errResultShape
}
if *wire.DevSdp == string(dependencymodels.CameraSDPRetryPending) {
return "", true, nil
}
return decodeCameraAnswer(*wire.DevSdp)
}
func decodeCameraAnswer(encoded string) (string, bool, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", false, fmt.Errorf("decode camera SDP: %w", err)
}
var envelope dependencymodels.CameraSDPEnvelope
err = json.Unmarshal(decoded, &envelope)
if err != nil {
return "", false, fmt.Errorf("decode camera SDP: %w", err)
}
if envelope.Type != dependencymodels.CameraSDPTypeAnswer || envelope.Sdp == "" {
return "", false, errResultShape
}
return envelope.Sdp, false, nil
}
package roborock
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"time"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencies/mqtt"
"github.com/portpowered/go-roborock/pkg/dependencies/rest"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
const defaultRequestTimeout = 30 * time.Second
// HTTPDoer lets callers inject every HTTP exchange.
type HTTPDoer interface {
Do(request *http.Request) (*http.Response, error)
}
// Option configures immutable service settings and network seams.
type Option func(*clientOptions) error
type clientOptions struct {
baseURL string
http HTTPDoer
dial mqtt.DialFunc
}
// Client owns immutable configuration. It retains no account tokens or connections.
type Client struct {
rest *rest.Client
dial mqtt.DialFunc
baseURL string
}
// WithBaseURL selects the regional HTTPS origin. Login requests may override it explicitly.
func WithBaseURL(origin string) Option {
return func(o *clientOptions) error {
if origin == "" {
return roborockerrors.New(roborockerrors.InvalidArgument, "configure", "origin is empty", nil)
}
o.baseURL = origin
return nil
}
}
// WithHTTPClient injects an HTTP client. The caller owns its transport.
func WithHTTPClient(client HTTPDoer) Option {
return func(o *clientOptions) error {
if client == nil {
return roborockerrors.New(roborockerrors.InvalidArgument, "configure", "HTTP client is nil", nil)
}
o.http = client
return nil
}
}
// WithMQTTDial injects a connection-producing network seam. It must honor context cancellation.
// The default dialer establishes verified TLS. An injected dialer owns transport security.
func WithMQTTDial(dial mqtt.DialFunc) Option {
return func(o *clientOptions) error {
if dial == nil {
return roborockerrors.New(roborockerrors.InvalidArgument, "configure", "MQTT dialer is nil", nil)
}
o.dial = dial
return nil
}
}
// NewClient validates functional options without accessing the network.
func NewClient(options ...Option) (*Client, error) {
var defaultHTTP http.Client
defaultHTTP.Timeout = defaultRequestTimeout
defaultHTTP.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
settings := clientOptions{baseURL: protocol.RESTDefaultOrigin, http: &defaultHTTP, dial: nil}
for _, option := range options {
if option == nil {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, "configure", "option is nil", nil)
}
err := option(&settings)
if err != nil {
return nil, err
}
}
opts := []rest.Option{rest.WithHTTPClient(settings.http)}
if settings.baseURL != "" {
opts = append(opts, rest.WithBaseURL(settings.baseURL))
}
restClient, err := rest.New(opts...)
if err != nil {
return nil, roborockerrors.Wrap(roborockerrors.InvalidArgument, "configure", "client configuration failed", err)
}
return &Client{rest: restClient, dial: settings.dial, baseURL: settings.baseURL}, nil
}
type deviceRPC interface {
Call(ctx context.Context, method string, parameters json.RawMessage) (json.RawMessage, error)
QueryA01(ctx context.Context, properties []int) (map[int]json.RawMessage, error)
SetA01(ctx context.Context, values map[int]json.RawMessage) error
Close() error
}
type deviceLifecycle interface {
Done() <-chan struct{}
Err() error
}
// DeviceSession owns a single account-bound device connection and its pending requests.
// Close releases the connection; canceling the opening context also closes the session.
type DeviceSession struct {
rpc deviceRPC
protocol string
family DeviceFamily
metadata deviceMetadata
client *Client
auth AuthContext
deviceID string
life context.Context
cancel context.CancelFunc
closeOnce sync.Once
closeErr error
terminalErr error
mu sync.Mutex
closing bool
camera *CameraSession
}
// OpenDevice opens a fresh connection; it never reuses another account's session.
func (c *Client) OpenDevice(ctx context.Context, request OpenDeviceRequest) (*DeviceSession, error) {
if request.Protocol == "" {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, "open_device", "device protocol is required", nil)
}
metadata, err := c.openMetadata(ctx, request)
if err != nil {
return nil, err
}
auth := request.Auth.Mqtt
rpc, err := mqtt.Open(ctx, mqtt.Config{BrokerURL: auth.BrokerURL,
User: auth.User,
Secret: auth.Secret,
Key: auth.Key,
DeviceID: request.DeviceID,
LocalKey: request.LocalKey,
Protocol: string(request.Protocol),
}, c.dial)
if err != nil {
return nil, roborockerrors.Wrap(roborockerrors.Unavailable, "open_device", "device connection failed", err)
}
life, cancel := context.WithCancel(ctx)
var session DeviceSession
session.rpc = rpc
session.protocol = string(request.Protocol)
session.family = metadata.family
session.metadata = metadata
session.client = c
session.auth = request.Auth
session.deviceID = request.DeviceID
session.life = life
session.cancel = cancel
go session.observeTransport(rpc)
return &session, nil
}
// Done closes when the opening context ends, the connection fails, or Close is called.
func (s *DeviceSession) Done() <-chan struct{} { return s.life.Done() }
// Err returns the terminal typed failure after Done closes, or nil while active.
// A connection failure retains its underlying cause after session cleanup.
func (s *DeviceSession) Err() error {
select {
case <-s.life.Done():
default:
return nil
}
s.mu.Lock()
terminal := s.terminalErr
s.mu.Unlock()
if terminal != nil {
return terminal
}
if transport, ok := s.rpc.(deviceLifecycle); ok {
err := transport.Err()
if err != nil {
return roborockerrors.Wrap(roborockerrors.Unavailable, "device_session", "device session ended", err)
}
}
err := s.life.Err()
if err != nil {
kind := roborockerrors.Canceled
if errors.Is(err, context.DeadlineExceeded) {
kind = roborockerrors.Timeout
}
return roborockerrors.New(kind, "device_session", "device context ended", err)
}
return nil
}
// Close cancels pending requests and releases the owned connection. Repeated calls are safe.
func (s *DeviceSession) Close() error {
s.closeOnce.Do(func() {
terminal := roborockerrors.New(roborockerrors.Closed, "device_session", "device session closed", nil)
if transport, ok := s.rpc.(deviceLifecycle); ok {
err := transport.Err()
if err != nil {
terminal = roborockerrors.Wrap(roborockerrors.Unavailable, "device_session", "device session ended", err)
}
}
s.mu.Lock()
s.closing = true
if s.terminalErr == nil {
s.terminalErr = terminal
}
camera := s.camera
s.mu.Unlock()
if camera != nil {
_ = camera.Close()
}
if s.cancel != nil {
s.cancel()
}
s.closeErr = s.rpc.Close()
})
return s.closeErr
}
func (s *DeviceSession) observeTransport(transport deviceLifecycle) {
select {
case <-s.life.Done():
cause := s.life.Err()
kind := roborockerrors.Canceled
if errors.Is(cause, context.DeadlineExceeded) {
kind = roborockerrors.Timeout
}
s.recordTerminalError(roborockerrors.New(kind, "device_session", "device context ended", cause))
case <-transport.Done():
s.recordTerminalError(roborockerrors.Wrap(
roborockerrors.Unavailable, "device_session", "device session ended", transport.Err(),
))
}
_ = s.Close()
}
func (s *DeviceSession) recordTerminalError(err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.terminalErr == nil {
s.terminalErr = err
}
}
package roborock
import (
"context"
"strconv"
"github.com/portpowered/go-roborock/pkg/dependencies/rest"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// GetHome returns the Roborock home identifier required by inventory requests.
func (c *Client) GetHome(ctx context.Context, request AccountRequest) (Home, error) {
out, err := c.rest.HomeDetail(ctx, rest.HomeDetailRequest{Auth: authWire(request.Auth)})
if err != nil {
return Home{}, roborockerrors.Wrap(roborockerrors.Protocol, "get_home", "home lookup failed", err)
}
return Home{ID: out.Home.RrHomeId,
Name: valueOrZero(out.Home.Name)}, nil
}
// GetHomeData joins device and product identity and includes shared devices.
// Unknown protocols are preserved; opening an unsupported version returns a typed error.
func (c *Client) GetHomeData(ctx context.Context, request HomeDataRequest) (HomeData, error) {
version := request.Version
if version == 0 {
version = HomeDataV1
}
out, err := c.rest.HomeData(ctx, rest.HomeDataRequest{Auth: authWire(request.Auth),
HomeID: request.HomeID,
Version: int(version),
})
if err != nil {
return HomeData{}, roborockerrors.Wrap(roborockerrors.Protocol, "get_home_data", "inventory lookup failed", err)
}
home := out.Home
products := make(map[string]dependencymodels.HomeProduct)
for _, p := range valueOrZero(home.Products) {
products[p.Id] = p
}
result := HomeData{Home: Home{ID: home.Id,
Name: home.Name},
Devices: []Device{},
Rooms: []Room{}}
seen := make(map[string]bool)
for _, device := range valueOrZero(home.Devices) {
result.Devices = append(result.Devices, deviceProjection(device, products[device.ProductId], false))
seen[device.Duid] = true
}
for _, device := range valueOrZero(home.ReceivedDevices) {
if !seen[device.Duid] {
result.Devices = append(result.Devices, deviceProjection(device, products[device.ProductId], true))
seen[device.Duid] = true
}
}
for _, room := range valueOrZero(home.Rooms) {
result.Rooms = append(result.Rooms, Room{ID: room.Id,
Name: room.Name})
}
return result, nil
}
func deviceProjection(device dependencymodels.HomeDevice, product dependencymodels.HomeProduct, shared bool) Device {
protocol := ProtocolVersion(valueOrZero(device.Pv))
if device.Pv == nil {
protocol = ProtocolV1
}
category := product.Category
family := productFamily(protocol, product.Model, category)
result := Device{ID: device.Duid,
Name: device.Name,
LocalKey: device.LocalKey,
Model: product.Model,
ProductID: device.ProductId,
Protocol: protocol,
Family: &family,
Online: device.Online,
Shared: shared,
Firmware: device.Fv,
Category: &category,
Capability: product.Capability,
SupportedProperties: advertisedProperties(product),
RoomID: nil,
}
result.RoomID = device.RoomId
return result
}
func advertisedProperties(product dependencymodels.HomeProduct) []int {
result := []int{}
seen := make(map[int]bool)
for _, property := range valueOrZero(product.Schema) {
if property.Id == nil {
continue
}
propertyID := numericPropertyID(*property.Id)
if propertyID > 0 && !seen[propertyID] {
result = append(result, propertyID)
seen[propertyID] = true
}
}
return result
}
// numericPropertyID accepts only positive representable integer property identifiers.
func numericPropertyID(property dependencymodels.ProductPropertyID) int {
numeric, err := property.AsProductPropertyID1()
if err == nil {
converted := int(numeric)
if numeric > 0 && int64(converted) == numeric {
return converted
}
return 0
}
text, err := property.AsProductPropertyID0()
if err != nil {
return 0
}
numericID, err := strconv.Atoi(text)
if err != nil || numericID <= 0 {
return 0
}
return numericID
}
// ListDevices performs home lookup followed by v1 inventory discovery.
func (c *Client) ListDevices(ctx context.Context, request AccountRequest) (ListDevicesResult, error) {
home, err := c.GetHome(ctx, request)
if err != nil {
return ListDevicesResult{}, err
}
data, err := c.GetHomeData(ctx, HomeDataRequest{Auth: request.Auth,
HomeID: home.ID,
Version: HomeDataV1})
if err != nil {
return ListDevicesResult{}, err
}
return ListDevicesResult{Devices: data.Devices}, nil
}
package roborock
import (
"context"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencies/rest"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
type deviceMetadata struct {
family DeviceFamily
serial, model string
}
func productFamily(version ProtocolVersion, model, category string) DeviceFamily {
switch version {
case ProtocolV1:
if category == protocol.DeviceCategoryVacuum {
return FamilyV1Vacuum
}
case ProtocolA01:
switch category {
case protocol.DeviceCategoryWetDry:
return FamilyDyad
case protocol.DeviceCategoryWasher:
return FamilyZeo
}
case ProtocolB01:
parts := strings.Split(model, ".")
suffix := parts[len(parts)-1]
if strings.Contains(suffix, protocol.DeviceQ10ModelPrefix) {
return FamilyB01Q10
}
if strings.Contains(suffix, protocol.DeviceQ7ModelPrefix) {
return FamilyB01Q7
}
case ProtocolL01:
return FamilyUnknown
}
return FamilyUnknown
}
func (s *DeviceSession) deviceFamily() DeviceFamily {
if s.family != "" {
return s.family
}
if s.protocol == string(ProtocolV1) {
return FamilyV1Vacuum
}
return FamilyUnknown
}
func (c *Client) openMetadata(ctx context.Context, request OpenDeviceRequest) (deviceMetadata, error) {
if request.Protocol != ProtocolB01 {
family := FamilyUnknown
if request.Protocol == ProtocolV1 {
family = FamilyV1Vacuum
}
return deviceMetadata{family: family, serial: "", model: ""}, nil
}
home, err := c.GetHome(ctx, AccountRequest{Auth: request.Auth})
if err != nil {
return deviceMetadata{}, err
}
data, err := c.rest.HomeData(ctx, rest.HomeDataRequest{Auth: authWire(request.Auth),
HomeID: home.ID, Version: int(HomeDataV1)})
if err != nil {
return deviceMetadata{}, operationError("open_device", err)
}
return metadataFromHome(data.Home, request.DeviceID)
}
func metadataFromHome(home dependencymodels.HomeData, deviceID string) (deviceMetadata, error) {
products := make(map[string]dependencymodels.HomeProduct)
for _, product := range valueOrZero(home.Products) {
products[product.Id] = product
}
for _, device := range valueOrZero(home.Devices) {
if device.Duid == deviceID {
return metadataFromDevice(device, products[device.ProductId])
}
}
for _, device := range valueOrZero(home.ReceivedDevices) {
if device.Duid == deviceID {
return metadataFromDevice(device, products[device.ProductId])
}
}
return deviceMetadata{}, roborockerrors.New(roborockerrors.NotFound,
"open_device", "device missing from account inventory", nil)
}
func metadataFromDevice(
device dependencymodels.HomeDevice, product dependencymodels.HomeProduct,
) (deviceMetadata, error) {
family := productFamily(ProtocolVersion(valueOrZero(device.Pv)), product.Model, product.Category)
if family != FamilyB01Q7 && family != FamilyB01Q10 {
return deviceMetadata{}, unsupportedMap("open_device")
}
metadata := deviceMetadata{family: family, serial: valueOrZero(device.Sn), model: product.Model}
if family == FamilyB01Q7 && metadata.serial == "" {
return deviceMetadata{}, roborockerrors.New(roborockerrors.Protocol,
"open_device", "Q7 inventory omits serial required for map decryption", nil)
}
return metadata, nil
}
// GetCapabilities reports supported operations independently of the implementation family.
func (s *DeviceSession) GetCapabilities(ctx context.Context, _ EmptyRequest) (DeviceCapabilities, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
err := ctx.Err()
if err != nil {
return DeviceCapabilities{}, operationError("GetCapabilities", err)
}
var capabilities DeviceCapabilities
switch s.deviceFamily() {
case FamilyV1Vacuum:
capabilities = DeviceCapabilities{MapContent: true, MapList: true, MapRooms: true,
MapSelection: true, MapTrace: false, RoomCleaning: true, ZoneCleaning: true}
case FamilyB01Q7:
capabilities = DeviceCapabilities{MapContent: true, MapList: true, MapRooms: true,
MapSelection: false, MapTrace: false, RoomCleaning: true, ZoneCleaning: false}
case FamilyB01Q10:
capabilities = DeviceCapabilities{MapContent: true, MapList: true, MapRooms: true,
MapSelection: false, MapTrace: true, RoomCleaning: true, ZoneCleaning: true}
case FamilyDyad, FamilyZeo, FamilyUnknown:
return capabilities, nil
}
return capabilities, nil
}
package roborock
import (
"context"
"encoding/json"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencies/mqtt"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
func (s *DeviceSession) cleanFamilyRooms(
ctx context.Context, request CleanSegmentsRequest,
) (CommandAcknowledgement, error) {
if s.deviceFamily() != FamilyB01Q7 && s.deviceFamily() != FamilyB01Q10 {
return CommandAcknowledgement{}, unsupportedMap("CleanSegments")
}
if request.Repeats != 1 {
return CommandAcknowledgement{}, unsupportedMap("CleanSegments repeats")
}
err := validateFamilyRoomIDs(s.deviceFamily(), request.Segments)
if err != nil {
return CommandAcknowledgement{}, err
}
transport, err := s.mapsTransport("CleanSegments")
if err != nil {
return CommandAcknowledgement{}, err
}
if s.deviceFamily() == FamilyB01Q10 {
return cleanQ10Rooms(ctx, transport, request.Segments)
}
return cleanQ7Rooms(ctx, transport, request.Segments)
}
func validateFamilyRoomIDs(family DeviceFamily, segments []int64) error {
minimum := int64(protocol.B01Q7RoomIDMinimum)
maximum := int64(protocol.B01Q7RoomIDMaximum)
if family == FamilyB01Q10 {
minimum = int64(protocol.B01Q10RoomIDMinimum)
maximum = int64(protocol.B01Q10RoomIDMaximum)
}
for _, id := range segments {
if id < minimum || id > maximum {
return roborockerrors.New(roborockerrors.InvalidArgument, "CleanSegments",
"room ID outside the family map identifier representation", nil)
}
}
return nil
}
func cleanQ10Rooms(ctx context.Context, transport *mapProvider, segments []int64) (CommandAcknowledgement, error) {
selection, err := json.Marshal(segments)
if err != nil {
return CommandAcknowledgement{}, operationError("CleanSegments", err)
}
err = transport.SetQ10Clean(ctx, dependencymodels.Q10CleanCommand{Cmd: dependencymodels.N2, CleanParamters: selection})
if err != nil {
return CommandAcknowledgement{}, operationError("CleanSegments", err)
}
return CommandAcknowledgement{Acknowledged: false, Sent: true}, nil
}
func cleanQ7Rooms(ctx context.Context, transport *mapProvider, segments []int64) (CommandAcknowledgement, error) {
parameters, err := json.Marshal(dependencymodels.Q7RoomCleanRequest{
CleanType: protocol.B01Q7RoomCleanType, CtrlValue: protocol.B01Q7CleanStart, RoomIds: segments})
if err != nil {
return CommandAcknowledgement{}, operationError("CleanSegments", err)
}
_, err = transport.CallB01(ctx, dependencymodels.ServiceSetRoomClean, parameters)
if err != nil {
return CommandAcknowledgement{}, operationError("CleanSegments", err)
}
return CommandAcknowledgement{Acknowledged: true, Sent: true}, nil
}
func (s *DeviceSession) cleanFamilyZones(
ctx context.Context, request CleanZonesRequest,
) (CommandAcknowledgement, error) {
if s.deviceFamily() != FamilyB01Q10 {
return CommandAcknowledgement{}, unsupportedMap("CleanZones")
}
if len(request.Zones) != 1 {
return CommandAcknowledgement{}, roborockerrors.New(roborockerrors.InvalidArgument,
"CleanZones", "Q10 accepts one rectangle per command", nil)
}
zone := request.Zones[0]
encoded, err := mqtt.EncodeQ10Zone(mqtt.Q10Zone{X1: zone.X1, Y1: zone.Y1,
X2: zone.X2, Y2: zone.Y2, Repeats: zone.Repeats})
if err != nil {
return CommandAcknowledgement{}, roborockerrors.Wrap(roborockerrors.InvalidArgument,
"CleanZones", "invalid Q10 rectangle", err)
}
parameters, err := json.Marshal(encoded)
if err != nil {
return CommandAcknowledgement{}, operationError("CleanZones", err)
}
transport, err := s.mapsTransport("CleanZones")
if err != nil {
return CommandAcknowledgement{}, err
}
err = transport.SetQ10Clean(ctx, dependencymodels.Q10CleanCommand{
Cmd: dependencymodels.N3, CleanParamters: parameters})
if err != nil {
return CommandAcknowledgement{}, operationError("CleanZones", err)
}
return CommandAcknowledgement{Acknowledged: false, Sent: true}, nil
}
package roborock
import (
"github.com/portpowered/go-roborock/pkg/dependencies/mapdata"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// MapToPixel projects a source-world point into the map raster using explicit calibration.
func MapToPixel(grid MapGrid, point MapPoint) (MapPoint, error) {
result, err := mapdata.MapToPixel(grid, point)
if err != nil {
return MapPoint{}, roborockerrors.New(roborockerrors.InvalidArgument, "MapToPixel", "invalid map geometry", err)
}
return result, nil
}
// PixelToMap converts a raster position to the source map's documented coordinate frame and unit.
func PixelToMap(grid MapGrid, point MapPoint) (MapPoint, error) {
result, err := mapdata.PixelToMap(grid, point)
if err != nil {
return MapPoint{}, roborockerrors.New(roborockerrors.InvalidArgument, "PixelToMap", "invalid map geometry", err)
}
return result, nil
}
// PixelRectangleToMap converts both corners and orders the source-world rectangle.
// Inspect grid.Frame and grid.Unit before using the result for a cleaning command.
func PixelRectangleToMap(grid MapGrid, rectangle MapRectangle) (MapRectangle, error) {
result, err := mapdata.PixelRectangleToMap(grid, rectangle)
if err != nil {
return MapRectangle{}, roborockerrors.New(roborockerrors.InvalidArgument,
"PixelRectangleToMap", "invalid map geometry", err)
}
return result, nil
}
package roborock
import (
"bytes"
"context"
"encoding/json"
"strconv"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
const roomMappingPairFields = 2
// GetRooms returns cleaning segment IDs and any reported names. Unknown names remain absent.
func (s *DeviceSession) GetRooms(ctx context.Context, _ EmptyRequest) (MapRoomsResult, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
err := ctx.Err()
if err != nil {
return MapRoomsResult{}, operationError("GetRooms", err)
}
if s.deviceFamily() == FamilyV1Vacuum {
return s.v1Rooms(ctx)
}
if s.deviceFamily() != FamilyB01Q7 && s.deviceFamily() != FamilyB01Q10 {
return MapRoomsResult{}, unsupportedMap("GetRooms")
}
snapshot, err := s.GetMap(ctx, GetMapRequest{MapID: ""})
if err != nil {
return MapRoomsResult{}, err
}
result := MapRoomsResult{Rooms: []MapRoomMapping{}}
for _, room := range snapshot.Rooms {
if room.Id != nil {
result.Rooms = append(result.Rooms, MapRoomMapping{SegmentID: *room.Id, Name: room.Name, IoTID: nil})
}
}
return result, nil
}
func (s *DeviceSession) operationContext(ctx context.Context) (context.Context, func()) {
bound, cancel := context.WithCancel(ctx)
if s.life == nil {
return bound, cancel
}
if s.life.Err() != nil {
cancel()
return bound, cancel
}
stop := context.AfterFunc(s.life, cancel)
return bound, func() { stop(); cancel() }
}
func (s *DeviceSession) v1Rooms(ctx context.Context) (MapRoomsResult, error) {
raw, err := s.callV1(ctx, dependencymodels.RPCMethod(protocol.RPCGetRoomMapping), dependencymodels.NoParameters{})
if err != nil {
return MapRoomsResult{}, err
}
result, err := parseRoomMapping(raw)
if err != nil {
return MapRoomsResult{}, err
}
if s.client == nil {
return result, nil
}
names, err := s.roomNames(ctx, result.Rooms)
if err != nil {
if ctx.Err() != nil {
return MapRoomsResult{}, operationError("GetRooms", ctx.Err())
}
return result, nil
}
for index := range result.Rooms {
room := &result.Rooms[index]
if room.IoTID != nil {
room.Name = names[*room.IoTID]
}
}
return result, nil
}
func parseRoomMapping(raw json.RawMessage) (MapRoomsResult, error) {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return MapRoomsResult{Rooms: []MapRoomMapping{}}, nil
}
var wire dependencymodels.MapsV1RoomsResult
err := json.Unmarshal(raw, &wire)
if err != nil {
return MapRoomsResult{}, roborockerrors.New(roborockerrors.Protocol, "GetRooms", "invalid room mapping", err)
}
pairs, err := wire.AsMapsV1RoomMappings()
if err != nil {
var pair dependencymodels.MapsV1RoomMappingPair
pair, err = wire.AsMapsV1RoomMappingPair()
if err != nil {
return MapRoomsResult{}, roborockerrors.New(roborockerrors.Protocol, "GetRooms", "invalid room mapping", err)
}
pairs = dependencymodels.MapsV1RoomMappings{pair}
}
result := MapRoomsResult{Rooms: []MapRoomMapping{}}
for _, pair := range pairs {
room, err := parseRoomPair(pair)
if err != nil {
return MapRoomsResult{}, err
}
result.Rooms = append(result.Rooms, room)
}
return result, nil
}
func parseRoomPair(pair dependencymodels.MapsV1RoomMappingPair) (MapRoomMapping, error) {
// Newer firmware appends a room type; only the segment and cloud IDs are used.
if len(pair) < roomMappingPairFields {
return MapRoomMapping{}, roborockerrors.New(roborockerrors.Protocol,
"GetRooms", "room mapping must contain segment and cloud IDs", nil)
}
if bytes.Equal(bytes.TrimSpace(pair[0]), []byte("null")) || bytes.Equal(bytes.TrimSpace(pair[1]), []byte("null")) {
return MapRoomMapping{}, roborockerrors.New(roborockerrors.Protocol, "GetRooms", "room IDs must be present", nil)
}
var segment int64
err := json.Unmarshal(pair[0], &segment)
if err != nil {
return MapRoomMapping{}, roborockerrors.New(roborockerrors.Protocol, "GetRooms", "invalid segment ID", err)
}
var identifier string
err = json.Unmarshal(pair[1], &identifier)
if err != nil {
var numeric int64
err = json.Unmarshal(pair[1], &numeric)
if err != nil {
return MapRoomMapping{}, roborockerrors.New(roborockerrors.Protocol, "GetRooms", "invalid cloud room ID", err)
}
identifier = strconv.FormatInt(numeric, 10)
}
return MapRoomMapping{SegmentID: segment, IoTID: &identifier, Name: nil}, nil
}
func (s *DeviceSession) roomNames(ctx context.Context, mappings []MapRoomMapping) (map[string]*string, error) {
home, err := s.client.GetHome(ctx, AccountRequest{Auth: s.auth})
if err != nil {
return nil, err
}
data, err := s.client.GetHomeData(ctx, HomeDataRequest{Auth: s.auth, HomeID: home.ID, Version: HomeDataV1})
if err != nil {
return nil, err
}
names := make(map[string]*string)
for _, room := range data.Rooms {
name := room.Name
names[strconv.FormatInt(room.ID, 10)] = &name
}
if !missingRoomNames(names, mappings) {
return names, nil
}
refreshed, err := s.refreshRoomNames(ctx, home.ID, data.Devices)
if err != nil {
if ctx.Err() != nil {
return nil, operationError("GetRooms", ctx.Err())
}
return names, nil
}
for _, room := range refreshed.Rooms {
names[strconv.FormatInt(room.ID, 10)] = room.Name
}
return names, nil
}
func missingRoomNames(names map[string]*string, mappings []MapRoomMapping) bool {
for _, mapping := range mappings {
if mapping.IoTID != nil && names[*mapping.IoTID] == nil {
return true
}
}
return false
}
func (s *DeviceSession) refreshRoomNames(ctx context.Context, homeID int64, devices []Device) (RoomsResult, error) {
shared := false
for _, device := range devices {
if device.ID == s.deviceID {
shared = device.Shared
break
}
}
if shared {
return s.client.GetSharedDeviceRooms(ctx, SharedDeviceRoomsRequest{Auth: s.auth, DeviceID: s.deviceID})
}
return s.client.GetHomeRooms(ctx, HomeRoomsRequest{Auth: s.auth, HomeID: homeID})
}
package roborock
import (
"context"
"encoding/json"
"strconv"
"github.com/portpowered/go-roborock/internal/mapmodel"
"github.com/portpowered/go-roborock/pkg/dependencies/mapdata"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
type mapTransport interface {
FetchMapV1(ctx context.Context) ([]byte, error)
CallB01(ctx context.Context, method dependencymodels.B01Method, params json.RawMessage) (json.RawMessage, error)
FetchMapQ7(ctx context.Context, mapID int64, serial, model string) ([]byte, error)
QueryMapListQ10(ctx context.Context) (dependencymodels.MapsQ10ListResult, error)
FetchMapQ10(ctx context.Context) ([]byte, error)
FetchTraceQ10(ctx context.Context) ([]byte, error)
SetQ10Clean(ctx context.Context, command dependencymodels.Q10CleanCommand) error
}
type mapProvider struct{ mapTransport }
func unsupportedMap(operation string) error {
return roborockerrors.New(roborockerrors.Unsupported, operation, "operation unsupported by device family", nil)
}
func (s *DeviceSession) mapsTransport(operation string) (*mapProvider, error) {
transport, ok := s.rpc.(mapTransport)
if !ok {
return nil, unsupportedMap(operation)
}
return &mapProvider{mapTransport: transport}, nil
}
// GetMap retrieves map content without changing the active map.
// Q10 returns the next matching stream observation while the request is pending.
func (s *DeviceSession) GetMap(ctx context.Context, request GetMapRequest) (MapSnapshot, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
transport, err := s.mapsTransport("GetMap")
if err != nil {
return MapSnapshot{}, err
}
payload, format, err := s.fetchMap(ctx, transport, request)
if err != nil {
return MapSnapshot{}, operationError("GetMap", err)
}
return decodeMap(format, payload, "GetMap")
}
func (s *DeviceSession) fetchMap(
ctx context.Context, transport *mapProvider, request GetMapRequest,
) ([]byte, mapmodel.MapFormat, error) {
switch s.deviceFamily() {
case FamilyV1Vacuum:
if request.MapID != "" {
return nil, mapmodel.MapFormatV1, unsupportedMap("GetMap explicit V1 map")
}
payload, err := transport.FetchMapV1(ctx)
return payload, mapmodel.MapFormatV1, err
case FamilyB01Q7:
mapID, err := s.q7MapID(ctx, request.MapID)
if err != nil {
return nil, mapmodel.MapFormatQ7, err
}
payload, err := transport.FetchMapQ7(ctx, mapID, s.metadata.serial, s.metadata.model)
return payload, mapmodel.MapFormatQ7, err
case FamilyB01Q10:
if request.MapID != "" {
return nil, mapmodel.MapFormatQ10, unsupportedMap("GetMap explicit Q10 map")
}
payload, err := transport.FetchMapQ10(ctx)
return payload, mapmodel.MapFormatQ10, err
case FamilyDyad, FamilyZeo, FamilyUnknown:
return nil, "", unsupportedMap("GetMap")
default:
return nil, "", unsupportedMap("GetMap")
}
}
func decodeMap(format mapmodel.MapFormat, payload []byte, operation string) (MapSnapshot, error) {
parsed, err := mapdata.Decode(format, payload)
if err != nil {
return MapSnapshot{}, roborockerrors.New(roborockerrors.Protocol, operation, "malformed device map", err)
}
return *parsed, nil
}
// GetMapTrace requests the next matching Q10 trace observation separately from map content.
// Q10 stream responses are uncorrelated; no cross-stream coherence is implied.
func (s *DeviceSession) GetMapTrace(ctx context.Context, _ EmptyRequest) (MapSnapshot, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
if s.deviceFamily() != FamilyB01Q10 {
return MapSnapshot{}, unsupportedMap("GetMapTrace")
}
transport, err := s.mapsTransport("GetMapTrace")
if err != nil {
return MapSnapshot{}, err
}
payload, err := transport.FetchTraceQ10(ctx)
if err != nil {
return MapSnapshot{}, operationError("GetMapTrace", err)
}
return decodeMap(mapmodel.MapFormatQ10, payload, "GetMapTrace")
}
func (s *DeviceSession) q7MapID(ctx context.Context, requested string) (int64, error) {
if requested != "" {
return numericMapID(requested, "GetMap")
}
maps, err := s.ListMaps(ctx, EmptyRequest{})
if err != nil {
return 0, err
}
return currentQ7Map(maps.Maps)
}
func currentQ7Map(maps []MapInfo) (int64, error) {
var selected *MapInfo
for _, item := range maps {
if item.Current != nil && *item.Current {
if selected != nil {
return 0, roborockerrors.New(roborockerrors.Protocol, "GetMap", "active map is ambiguous", nil)
}
selected = &item
}
}
if selected != nil {
return numericMapID(selected.ID, "GetMap")
}
return 0, roborockerrors.New(roborockerrors.Unavailable, "GetMap", "device did not identify its active map", nil)
}
func numericMapID(value, operation string) (int64, error) {
identifier, err := strconv.ParseInt(value, 10, 64)
if err != nil || identifier < 0 {
return 0, roborockerrors.New(roborockerrors.InvalidArgument,
operation, "map identifier is not a nonnegative decimal integer", err)
}
return identifier, nil
}
var _ MapOperations = (*DeviceSession)(nil)
package roborock
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/portpowered/go-roborock/internal/protocol"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
var errMalformedMapList = errors.New("device map list does not match its known response shape")
// ListMaps retrieves saved-map metadata. Q10 list order does not imply an active map.
func (s *DeviceSession) ListMaps(ctx context.Context, _ EmptyRequest) (ListMapsResult, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
err := ctx.Err()
if err != nil {
return ListMapsResult{}, operationError("ListMaps", err)
}
result := ListMapsResult{Maps: []MapInfo{}}
switch s.deviceFamily() {
case FamilyV1Vacuum:
return s.listV1Maps(ctx)
case FamilyB01Q7:
return s.listQ7Maps(ctx)
case FamilyB01Q10:
return s.listQ10Maps(ctx)
case FamilyDyad, FamilyZeo, FamilyUnknown:
return result, unsupportedMap("ListMaps")
default:
return result, unsupportedMap("ListMaps")
}
}
func (s *DeviceSession) listQ7Maps(ctx context.Context) (ListMapsResult, error) {
result := ListMapsResult{Maps: []MapInfo{}}
transport, err := s.mapsTransport("ListMaps")
if err != nil {
return result, err
}
parameters, err := json.Marshal(dependencymodels.MapsQ7ListRequest{})
if err != nil {
return result, operationError("ListMaps", err)
}
raw, err := transport.CallB01(ctx, dependencymodels.ServiceGetMapList, parameters)
if err != nil {
return result, operationError("ListMaps", err)
}
var list dependencymodels.MapsQ7ListResult
err = decodeKnownMapList(raw, &list)
if err != nil {
return result, roborockerrors.New(roborockerrors.Protocol, "ListMaps", "malformed Q7 map list", err)
}
for _, entry := range valueOrZero(list.MapList) {
if entry.Id != nil {
result.Maps = append(result.Maps, MapInfo{ID: strconv.FormatInt(*entry.Id, 10), Current: entry.Cur, Name: nil})
}
}
return result, nil
}
func (s *DeviceSession) listQ10Maps(ctx context.Context) (ListMapsResult, error) {
result := ListMapsResult{Maps: []MapInfo{}}
transport, err := s.mapsTransport("ListMaps")
if err != nil {
return result, err
}
list, err := transport.QueryMapListQ10(ctx)
if err != nil {
return result, operationError("ListMaps", err)
}
for _, entry := range valueOrZero(list.Data) {
result.Maps = append(result.Maps, MapInfo{ID: entry.Id, Name: entry.Name, Current: nil})
}
return result, nil
}
func (s *DeviceSession) listV1Maps(ctx context.Context) (ListMapsResult, error) {
result := ListMapsResult{Maps: []MapInfo{}}
raw, err := s.callV1(ctx, dependencymodels.RPCMethod(protocol.RPCGetMultiMapsList), dependencymodels.NoParameters{})
if err != nil {
return result, err
}
var lists dependencymodels.MapsV1ListResult
err = decodeKnownMapList(raw, &lists)
if err != nil {
return result, roborockerrors.New(roborockerrors.Protocol, "ListMaps", "malformed V1 map list", err)
}
if len(lists) == 0 {
return result, roborockerrors.New(roborockerrors.Protocol, "ListMaps", "empty V1 map list response",
errMalformedMapList)
}
for _, list := range lists {
for _, entry := range valueOrZero(list.MapInfo) {
name := entry.Name
result.Maps = append(result.Maps, MapInfo{ID: strconv.FormatInt(entry.MapFlag, 10), Name: &name, Current: nil})
}
}
return result, nil
}
func decodeKnownMapList(raw json.RawMessage, target any) error {
err := json.Unmarshal(raw, target)
if err != nil {
return fmt.Errorf("decode device map list: %w", err)
}
return validateMapListShape(raw, reflect.TypeOf(target).Elem())
}
func validateMapListShape(raw json.RawMessage, model reflect.Type) error {
for model.Kind() == reflect.Pointer {
model = model.Elem()
}
if model == reflect.TypeFor[json.RawMessage]() {
return nil
}
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return errMalformedMapList
}
switch {
case model.Kind() == reflect.Struct:
return validateMapListObject(raw, model)
case model.Kind() == reflect.Slice:
var values []json.RawMessage
err := json.Unmarshal(raw, &values)
if err != nil {
return fmt.Errorf("decode map list sequence: %w", err)
}
for _, value := range values {
err = validateMapListShape(value, model.Elem())
if err != nil {
return err
}
}
default:
}
return nil
}
func validateMapListObject(raw json.RawMessage, model reflect.Type) error {
var object map[string]json.RawMessage
err := json.Unmarshal(raw, &object)
if err != nil {
return fmt.Errorf("decode map list object: %w", err)
}
for index := range model.NumField() {
field := model.Field(index)
tag := field.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
parts := strings.Split(tag, ",")
value, present := object[parts[0]]
if !present {
if !strings.Contains(tag, ",omitempty") {
return fmt.Errorf("%w: missing %s", errMalformedMapList, parts[0])
}
continue
}
// An optional member sent as null is treated as absent.
if strings.Contains(tag, ",omitempty") && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
continue
}
err = validateMapListShape(value, field.Type)
if err != nil {
return err
}
}
return nil
}
// SelectMap explicitly changes the V1 active map. Reads never select maps implicitly.
func (s *DeviceSession) SelectMap(ctx context.Context, request SelectMapRequest) (CommandAcknowledgement, error) {
ctx, finish := s.operationContext(ctx)
defer finish()
if s.deviceFamily() != FamilyV1Vacuum {
return CommandAcknowledgement{}, unsupportedMap("SelectMap")
}
identifier, err := numericMapID(request.MapID, "SelectMap")
if err != nil {
return CommandAcknowledgement{}, err
}
return s.command(ctx, dependencymodels.RPCMethod(protocol.RPCLoadMultiMap),
dependencymodels.MapsV1LoadRequest{identifier})
}
package roborock
import (
"context"
"github.com/portpowered/go-roborock/pkg/dependencies/rest"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// GetHomeRooms refreshes the home room list without fetching device inventory.
func (c *Client) GetHomeRooms(ctx context.Context, request HomeRoomsRequest) (RoomsResult, error) {
out, err := c.rest.HomeRooms(ctx, rest.HomeRoomsRequest{Auth: authWire(request.Auth), HomeID: request.HomeID})
if err != nil {
return RoomsResult{}, roborockerrors.Wrap(roborockerrors.Protocol, "get_home_rooms", "room refresh failed", err)
}
return roomsProjection(out), nil
}
// GetSharedDeviceRooms refreshes rooms belonging to a received device.
func (c *Client) GetSharedDeviceRooms(ctx context.Context, request SharedDeviceRoomsRequest) (RoomsResult, error) {
out, err := c.rest.SharedDeviceRooms(ctx, rest.SharedDeviceRoomsRequest{
Auth: authWire(request.Auth), DeviceID: request.DeviceID,
})
if err != nil {
return RoomsResult{}, roborockerrors.Wrap(roborockerrors.Protocol,
"get_shared_device_rooms", "room refresh failed", err)
}
return roomsProjection(out), nil
}
func roomsProjection(out rest.RoomsResult) RoomsResult {
result := RoomsResult{Rooms: make([]CloudRoom, 0, len(out.Rooms))}
for _, room := range out.Rooms {
result.Rooms = append(result.Rooms, CloudRoom{ID: room.Id, Name: room.Name})
}
return result
}
package roborock
import (
"context"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
// DeviceOperations lists the typed V1 vacuum controls supported by a DeviceSession.
// Acknowledgements report RPC acceptance; observe GetStatus for physical completion.
type DeviceOperations interface {
GetStatus(ctx context.Context, _ EmptyRequest) (Status, error)
GetConsumables(ctx context.Context, _ EmptyRequest) (Consumables, error)
GetCleaningSummary(ctx context.Context, _ EmptyRequest) (CleaningSummary, error)
GetCleanRecord(ctx context.Context, req CleanRecordRequest) (CleanRecordResult, error)
GetDND(ctx context.Context, _ EmptyRequest) (DND, error)
StartCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
StopCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
PauseCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
ReturnToDock(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
SpotClean(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
StartDustCollection(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
StopDustCollection(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
StartMopWashing(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
StopMopWashing(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
RCStart(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
RCStop(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
RCEnd(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
DisableDND(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error)
SetFanSpeed(ctx context.Context, req SetFanSpeedRequest) (CommandAcknowledgement, error)
SetWaterMode(ctx context.Context, req SetWaterModeRequest) (CommandAcknowledgement, error)
SetMopMode(ctx context.Context, req SetMopModeRequest) (CommandAcknowledgement, error)
SetCleanMotorMode(ctx context.Context, req SetCleanMotorModeRequest) (CommandAcknowledgement, error)
SetDND(ctx context.Context, req SetDNDRequest) (CommandAcknowledgement, error)
CleanZones(ctx context.Context, req CleanZonesRequest) (CommandAcknowledgement, error)
CleanSegments(ctx context.Context, req CleanSegmentsRequest) (CommandAcknowledgement, error)
RCMove(ctx context.Context, req RCMoveRequest) (CommandAcknowledgement, error)
}
// StartCleaning sends the device command once without automatic retries.
func (s *DeviceSession) StartCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStartCleaning, dependencymodels.NoParameters{})
}
// StopCleaning sends the device command once without automatic retries.
func (s *DeviceSession) StopCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStopCleaning, dependencymodels.NoParameters{})
}
// PauseCleaning sends the device command once without automatic retries.
func (s *DeviceSession) PauseCleaning(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCPauseCleaning, dependencymodels.NoParameters{})
}
// ReturnToDock sends the device command once without automatic retries.
func (s *DeviceSession) ReturnToDock(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCReturnToDock, dependencymodels.NoParameters{})
}
// SpotClean sends the device command once without automatic retries.
func (s *DeviceSession) SpotClean(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCSpotClean, dependencymodels.NoParameters{})
}
// StartDustCollection sends the device command once without automatic retries.
func (s *DeviceSession) StartDustCollection(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStartDustCollection, dependencymodels.NoParameters{})
}
// StopDustCollection sends the device command once without automatic retries.
func (s *DeviceSession) StopDustCollection(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStopDustCollection, dependencymodels.NoParameters{})
}
// StartMopWashing sends the device command once without automatic retries.
func (s *DeviceSession) StartMopWashing(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStartMopWashing, dependencymodels.NoParameters{})
}
// StopMopWashing sends the device command once without automatic retries.
func (s *DeviceSession) StopMopWashing(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCStopMopWashing, dependencymodels.NoParameters{})
}
// RCStart sends the device command once without automatic retries.
func (s *DeviceSession) RCStart(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCRCStart, dependencymodels.NoParameters{})
}
// RCStop sends the device command once without automatic retries.
func (s *DeviceSession) RCStop(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCRCStop, dependencymodels.NoParameters{})
}
// RCEnd sends the device command once without automatic retries.
func (s *DeviceSession) RCEnd(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCRCEnd, dependencymodels.NoParameters{})
}
// DisableDND sends the device command once without automatic retries.
func (s *DeviceSession) DisableDND(ctx context.Context, _ EmptyRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCDisableDND, dependencymodels.NoParameters{})
}
// SetFanSpeed sends the device command once without automatic retries.
func (s *DeviceSession) SetFanSpeed(ctx context.Context, req SetFanSpeedRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCSetFanSpeed, dependencymodels.IntegerParameters{int64(req.Speed)})
}
// SetWaterMode sends the device command once without automatic retries.
func (s *DeviceSession) SetWaterMode(ctx context.Context, req SetWaterModeRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCSetWaterMode, dependencymodels.IntegerParameters{int64(req.Mode)})
}
// SetMopMode sends the device command once without automatic retries.
func (s *DeviceSession) SetMopMode(ctx context.Context, req SetMopModeRequest) (CommandAcknowledgement, error) {
return s.command(ctx, dependencymodels.RPCSetMopMode, dependencymodels.IntegerParameters{int64(req.Mode)})
}
// SetCleanMotorMode sets fan speed, water mode and optional mop route together, as the Roborock
// app's cleaning-mode selector does; WaterModeOff selects vacuum only. It takes raw mode codes,
// unlike python-roborock's high-level set_cleaning_mode. An S8 MaxV Ultra in the app's Custom mode
// rejected SetWaterMode (-10005) but accepted this command. It is sent once without retries.
func (s *DeviceSession) SetCleanMotorMode(
ctx context.Context,
req SetCleanMotorModeRequest,
) (CommandAcknowledgement, error) {
mode := dependencymodels.CleanMotorMode{
FanPower: int64(req.FanSpeed), WaterBoxMode: int64(req.WaterMode), MopMode: nil,
}
if req.MopMode != nil {
route := int64(*req.MopMode)
mode.MopMode = &route
}
return s.command(ctx, dependencymodels.RPCSetCleanMotorMode, dependencymodels.CleanMotorModeParameters{mode})
}
// SetDND sends the device command once without automatic retries.
func (s *DeviceSession) SetDND(ctx context.Context, req SetDNDRequest) (CommandAcknowledgement, error) {
if !validDND(req) {
return CommandAcknowledgement{}, roborockerrors.New(
roborockerrors.InvalidArgument,
"SetDND",
"hours must be 0 through 23 and minutes 0 through 59",
nil,
)
}
return s.command(
ctx,
dependencymodels.RPCSetDND,
dependencymodels.IntegerParameters{req.StartHour, req.StartMinute, req.EndHour, req.EndMinute},
)
}
// CleanZones sends the device command once without automatic retries.
func (s *DeviceSession) CleanZones(ctx context.Context, req CleanZonesRequest) (CommandAcknowledgement, error) {
if s.deviceFamily() != FamilyV1Vacuum {
return s.cleanFamilyZones(ctx, req)
}
params, err := zoneParameters(req)
if err != nil {
return CommandAcknowledgement{}, err
}
return s.command(ctx, dependencymodels.RPCCleanZones, params)
}
// CleanSegments sends the device command once without automatic retries.
func (s *DeviceSession) CleanSegments(ctx context.Context, req CleanSegmentsRequest) (CommandAcknowledgement, error) {
if len(req.Segments) == 0 || req.Repeats < 1 || req.Repeats > 3 {
return CommandAcknowledgement{}, roborockerrors.New(
roborockerrors.InvalidArgument,
"CleanSegments",
"segments required and repeats must be 1 through 3",
nil,
)
}
if s.deviceFamily() != FamilyV1Vacuum {
return s.cleanFamilyRooms(ctx, req)
}
return s.command(
ctx,
dependencymodels.RPCCleanSegments,
dependencymodels.SegmentParameters{
dependencymodels.SegmentCommand{Segments: req.Segments, Repeat: int64(req.Repeats)},
},
)
}
// RCMove sends the device command once without automatic retries.
func (s *DeviceSession) RCMove(ctx context.Context, req RCMoveRequest) (CommandAcknowledgement, error) {
if !validRC(req) {
return CommandAcknowledgement{}, roborockerrors.New(
roborockerrors.InvalidArgument,
"RCMove",
"invalid velocity, omega, duration, or sequence",
nil,
)
}
return s.command(
ctx,
dependencymodels.RPCRCMove,
dependencymodels.RCParameters{
dependencymodels.RCCommand{
Velocity: req.Velocity,
Omega: req.Omega,
Duration: req.Duration,
Seqnum: int64(req.Sequence),
},
},
)
}
package roborock
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
func (s *DeviceSession) callV1(
ctx context.Context,
method dependencymodels.RPCMethod,
params any,
) (json.RawMessage, error) {
if s.protocol != string(ProtocolV1) {
return nil, roborockerrors.New(
roborockerrors.Unsupported,
string(method),
"operation requires a V1 device",
nil,
)
}
err := ctx.Err()
if err != nil {
return nil, operationError(string(method), err)
}
payload, err := json.Marshal(params)
if err != nil {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, string(method), "cannot encode parameters", err)
}
result, err := s.rpc.Call(ctx, string(method), payload)
if err != nil {
return nil, operationError(string(method), err)
}
return result, nil
}
func operationError(operation string, cause error) error {
var typed *roborockerrors.Error
if errors.As(cause, &typed) {
return cause
}
kind := roborockerrors.Unavailable
if errors.Is(cause, context.DeadlineExceeded) {
kind = roborockerrors.Timeout
}
if errors.Is(cause, context.Canceled) {
kind = roborockerrors.Canceled
}
return roborockerrors.New(kind, operation, "device operation failed", cause)
}
func (s *DeviceSession) command(
ctx context.Context,
method dependencymodels.RPCMethod,
params any,
) (CommandAcknowledgement, error) {
raw, err := s.callV1(ctx, method, params)
if err != nil {
return CommandAcknowledgement{}, err
}
var ack dependencymodels.Acknowledgement
err = json.Unmarshal(raw, &ack)
if err != nil || len(ack) != 1 || ack[0] != dependencymodels.CommandAcceptanceOK {
return CommandAcknowledgement{}, roborockerrors.New(
roborockerrors.Protocol,
string(method),
"device did not acknowledge command",
err,
)
}
return CommandAcknowledgement{Acknowledged: true, Sent: true}, nil
}
func validDND(req SetDNDRequest) bool {
return req.StartHour >= 0 && req.StartHour <= 23 &&
req.EndHour >= 0 && req.EndHour <= 23 &&
req.StartMinute >= 0 && req.StartMinute <= 59 &&
req.EndMinute >= 0 && req.EndMinute <= 59
}
func validRC(req RCMoveRequest) bool {
return !math.IsNaN(float64(req.Velocity)) && !math.IsNaN(float64(req.Omega)) &&
req.Velocity >= -0.3 && req.Velocity <= 0.3 &&
req.Omega >= -0.8 && req.Omega <= 0.8 &&
req.Duration >= 1 && req.Duration <= 5000 && req.Sequence >= 0
}
func zoneParameters(req CleanZonesRequest) (dependencymodels.ZoneParameters, error) {
if len(req.Zones) == 0 {
return nil, roborockerrors.New(roborockerrors.InvalidArgument, "CleanZones", "zones are required", nil)
}
params := make(dependencymodels.ZoneParameters, 0, len(req.Zones))
for _, zone := range req.Zones {
if zone.Repeats < 1 || zone.Repeats > 3 || zone.X1 >= zone.X2 || zone.Y1 >= zone.Y2 {
return nil, roborockerrors.New(
roborockerrors.InvalidArgument,
"CleanZones",
"zones require ordered corners and repeats 1 through 3",
nil,
)
}
params = append(params, []int64{zone.X1, zone.Y1, zone.X2, zone.Y2, int64(zone.Repeats)})
}
return params, nil
}
func objectResult(raw json.RawMessage) (json.RawMessage, error) {
raw = bytes.TrimSpace(raw)
if len(raw) > 0 && raw[0] == '[' {
var items []json.RawMessage
err := json.Unmarshal(raw, &items)
if err != nil {
return nil, fmt.Errorf("decode device object: %w", err)
}
if len(items) != 1 {
return nil, errResultShape
}
raw = bytes.TrimSpace(items[0])
}
if len(raw) == 0 || raw[0] != '{' {
return nil, errResultShape
}
var fields map[string]json.RawMessage
err := json.Unmarshal(raw, &fields)
if err != nil {
return nil, fmt.Errorf("decode device object: %w", err)
}
if len(fields) == 0 {
return nil, errResultShape
}
return raw, nil
}
// projectResult checks the generated wire contract before projecting into the
// independent generated public type. Shared JSON names are part of the schema contract.
func projectResult[Wire, Public any](raw json.RawMessage) (Public, error) {
var (
wire Wire
public Public
)
err := json.Unmarshal(raw, &wire)
if err != nil {
return public, fmt.Errorf("project device result: %w", err)
}
projected, err := json.Marshal(wire)
if err != nil {
return public, fmt.Errorf("project device result: %w", err)
}
err = json.Unmarshal(projected, &public)
if err != nil {
return public, fmt.Errorf("decode projected result: %w", err)
}
return public, nil
}
func readObject[Wire, Public any](
ctx context.Context,
s *DeviceSession,
method dependencymodels.RPCMethod,
) (Public, error) {
var empty Public
raw, err := s.callV1(ctx, method, dependencymodels.NoParameters{})
if err != nil {
return empty, err
}
raw, err = objectResult(raw)
if err != nil {
return empty, roborockerrors.New(roborockerrors.Protocol, string(method), "invalid device result", err)
}
result, err := projectResult[Wire, Public](raw)
if err != nil {
return empty, roborockerrors.New(roborockerrors.Protocol, string(method), "invalid device result", err)
}
return result, nil
}
package roborock
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/portpowered/go-roborock/pkg/dependencymodels"
"github.com/portpowered/go-roborock/pkg/roborockerrors"
)
const legacyTupleFields = 4
var errResultShape = errors.New("invalid device result shape")
// GetStatus reads presence-aware status; zero and absent measurements remain distinct.
func (s *DeviceSession) GetStatus(ctx context.Context, _ EmptyRequest) (Status, error) {
return readObject[dependencymodels.Status, Status](ctx, s, dependencymodels.RPCGetStatus)
}
// GetConsumables reads work counters in seconds or the documented device counter units.
func (s *DeviceSession) GetConsumables(ctx context.Context, _ EmptyRequest) (Consumables, error) {
return readObject[dependencymodels.Consumables, Consumables](ctx, s, dependencymodels.RPCGetConsumables)
}
// GetDND reads the device's local-hour do-not-disturb schedule.
func (s *DeviceSession) GetDND(ctx context.Context, _ EmptyRequest) (DND, error) {
return readObject[dependencymodels.DND, DND](ctx, s, dependencymodels.RPCGetDND)
}
// GetCleaningSummary accepts object, legacy tuple, and single-duration V1 results.
func (s *DeviceSession) GetCleaningSummary(ctx context.Context, _ EmptyRequest) (CleaningSummary, error) {
raw, err := s.callV1(ctx, dependencymodels.RPCGetCleaningSummary, dependencymodels.NoParameters{})
if err != nil {
return CleaningSummary{}, err
}
result, err := decodeSummary(bytes.TrimSpace(raw))
if err != nil {
return CleaningSummary{}, roborockerrors.New(
roborockerrors.Protocol, "GetCleaningSummary", "invalid cleaning summary", err,
)
}
return result, nil
}
func decodeSummary(raw json.RawMessage) (CleaningSummary, error) {
var result CleaningSummary
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return result, errResultShape
}
switch raw[0] {
case '{':
_, err := objectResult(raw)
if err != nil {
return result, err
}
return projectResult[dependencymodels.CleaningSummary, CleaningSummary](raw)
case '[':
return decodeSummaryTuple(raw)
default:
err := json.Unmarshal(raw, &result.CleanTime)
if err != nil {
return result, fmt.Errorf("decode summary duration: %w", err)
}
return result, nil
}
}
func decodeSummaryTuple(raw json.RawMessage) (CleaningSummary, error) {
var (
result CleaningSummary
values dependencymodels.CleaningSummaryLegacy
)
err := json.Unmarshal(raw, &values)
if err != nil {
return result, fmt.Errorf("decode summary tuple: %w", err)
}
if len(values) == 1 && len(values[0]) > 0 && values[0][0] == '{' {
return decodeSummary(values[0])
}
if len(values) == 0 || len(values) > legacyTupleFields {
return result, errResultShape
}
fields := []**int64{&result.CleanTime, &result.CleanArea, &result.CleanCount}
err = decodeIntegerTuple(values, fields)
if err != nil {
return result, err
}
if len(values) == legacyTupleFields {
err := json.Unmarshal(values[legacyTupleFields-1], &result.Records)
if err != nil {
return result, fmt.Errorf("decode summary records: %w", err)
}
}
return result, nil
}
// GetCleanRecord reads a record ID. Multi-part results preserve every record in order.
func (s *DeviceSession) GetCleanRecord(ctx context.Context, req CleanRecordRequest) (CleanRecordResult, error) {
if req.RecordId <= 0 {
return CleanRecordResult{}, roborockerrors.New(
roborockerrors.InvalidArgument, "GetCleanRecord", "record ID must be positive", nil,
)
}
raw, err := s.callV1(ctx, dependencymodels.RPCGetCleanRecord, dependencymodels.IntegerParameters{req.RecordId})
if err != nil {
return CleanRecordResult{}, err
}
records, err := decodeRecords(bytes.TrimSpace(raw))
if err != nil {
return CleanRecordResult{}, roborockerrors.New(
roborockerrors.Protocol, "GetCleanRecord", "invalid cleaning record", err,
)
}
return CleanRecordResult{Records: records}, nil
}
func decodeRecords(raw json.RawMessage) ([]CleanRecord, error) {
if len(raw) == 0 {
return nil, errResultShape
}
if raw[0] == '{' {
_, err := objectResult(raw)
if err != nil {
return nil, err
}
record, err := projectResult[dependencymodels.CleanRecord, CleanRecord](raw)
return []CleanRecord{record}, err
}
var values []json.RawMessage
err := json.Unmarshal(raw, &values)
if err != nil {
return nil, fmt.Errorf("decode record list: %w", err)
}
if len(values) == 0 {
return nil, errResultShape
}
if len(values[0]) > 0 && (values[0][0] == '{' || values[0][0] == '[') {
return decodeRecordParts(values)
}
return decodeRecordTuple(values)
}
func decodeRecordParts(values []json.RawMessage) ([]CleanRecord, error) {
var result []CleanRecord
for _, value := range values {
records, err := decodeRecordPart(value)
if err != nil {
return nil, err
}
result = append(result, records...)
}
return result, nil
}
func decodeRecordPart(raw json.RawMessage) ([]CleanRecord, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil, errResultShape
}
if raw[0] == '{' {
_, err := objectResult(raw)
if err != nil {
return nil, err
}
record, err := projectResult[dependencymodels.CleanRecord, CleanRecord](raw)
return []CleanRecord{record}, err
}
var values []json.RawMessage
err := json.Unmarshal(raw, &values)
if err != nil {
return nil, fmt.Errorf("decode record part: %w", err)
}
return decodeRecordTuple(values)
}
func decodeRecordTuple(values []json.RawMessage) ([]CleanRecord, error) {
if len(values) < legacyTupleFields {
return nil, errResultShape
}
columns := make([]*int64, len(values))
for index, value := range values {
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return nil, errResultShape
}
err := json.Unmarshal(value, &columns[index])
if err != nil {
return nil, fmt.Errorf("decode record tuple: %w", err)
}
}
var record CleanRecord
record.Begin, record.End, record.Duration, record.Area = columns[0], columns[1], columns[2], columns[3]
return []CleanRecord{record}, nil
}
func decodeIntegerTuple(values []json.RawMessage, fields []**int64) error {
for index, field := range fields {
if index >= len(values) {
break
}
err := json.Unmarshal(values[index], field)
if err != nil {
return fmt.Errorf("decode integer tuple: %w", err)
}
}
return nil
}
// Package roborockerrors defines failures shared by the public API and transports.
package roborockerrors
import (
"errors"
"fmt"
)
// Kind is a stable failure class.
type Kind string
const (
// InvalidArgument indicates an invalid request or option.
InvalidArgument Kind = "invalid_argument"
// Unauthorized indicates missing, rejected, or expired credentials.
Unauthorized Kind = "unauthorized"
// NotFound indicates a missing account or device.
NotFound Kind = "not_found"
// RateLimited indicates vendor throttling.
RateLimited Kind = "rate_limited"
// Unavailable indicates a failed connection or unavailable service.
Unavailable Kind = "unavailable"
// Timeout indicates that a request deadline elapsed.
Timeout Kind = "timeout"
// Canceled indicates caller cancellation.
Canceled Kind = "canceled"
// Protocol indicates a malformed response or rejected operation.
Protocol Kind = "protocol"
// Closed indicates a closed session.
Closed Kind = "closed"
// Unsupported indicates an unsupported protocol or capability.
Unsupported Kind = "unsupported"
// Backpressure indicates a full session queue.
Backpressure Kind = "backpressure"
)
// Error preserves a failure class, operation, vendor code, and underlying cause.
// Message must not contain credentials or untrusted response bodies.
type Error struct {
Kind Kind
Operation string
Code int
Message string
Cause error
}
func (e *Error) Error() string {
return fmt.Sprintf("roborock %s: %s (%s, code %d)", e.Operation, e.Message, e.Kind, e.Code)
}
// Unwrap exposes the cause for errors.Is and errors.As.
func (e *Error) Unwrap() error { return e.Cause }
// Is matches the requested failure kind; empty target kinds do not match.
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && t.Kind != "" && e.Kind == t.Kind
}
// New creates a failure with a caller-safe message and retained cause.
func New(kind Kind, operation, message string, cause error) *Error {
return &Error{Kind: kind, Operation: operation, Code: 0, Message: message, Cause: cause}
}
// Wrap preserves a typed failure's classification and code with a caller-safe operation.
// Untyped failures use fallback without exposing the underlying cause in the message.
func Wrap(fallback Kind, operation, message string, cause error) *Error {
wrapped := New(fallback, operation, message, cause)
var typed *Error
if errors.As(cause, &typed) {
wrapped.Kind = typed.Kind
wrapped.Code = typed.Code
}
return wrapped
}