Recently, I moved to a new apartment and “inherited” a subscription to the home alarm service Homely. This system has a series of smoke detectors, motion sensors, door sensors and a Yale door lock. These devices all communicate their state back to Homely through the Zigbee gateway. I also run an instance of Home Assitant with Zigbee2MQTT for the rest of my home automation needs. Sadly, the Homly Zigbee network can’t connect to my Home Assitant network. There is also no other way to integrate these locally, as far as I know at least.

So, I started searching for a Home Assitant integration. There are some efforts in this direction if you follow this thread in a Norwegian home automation forum.. Additionally, github.com/hansrune/homely-tools implements a small application to push changes from Homely’s API to MQTT. The API is currently in beta, and you can get some documentation if you ask their customer support. The documentation, however, is lacking. But I guess you can’t expect too much of an semi-public beta API. Using my language og choice, Go, I did some exploring of their API. The API client I wrote can be found at github.com/tokongs/homely. For now, and I suspect maybe forever, the API is read only.

Authentication

To authenticate against their API they seem to implement the OAuth2 password grant flow. Using this grant you can exchange your credentials for a JWT. Just send a POST request to the https://sdk.iotiliti.cloud/homely/oauth/token endpoint with a payload like {"username": "<email>", "password": "password"}. Using the TokenSource from golang.org/x/oauth2 we can automatically deal with fetching tokens.

type tokenPayload struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type tokenResponse struct {
	AccessToken      string    `json:"access_token"`
	ExpiresIn        int       `json:"expires_in"`
	RefreshExpiresIn int       `json:"refresh_expires_in"`
	RefreshToken     string    `json:"refresh_token"`
	TokenType        string    `json:"token_type:"`
	NotBeforePolicy  int       `json:"not-before-policy"`
	SessionState     uuid.UUID `json:"session_state"`
	Scope            string    `json:"scope"`
}

// tokenSource implements golang.org/x/oauth2.TokenSource and 
can be used to create and authenticated http.Client
type tokenSource struct {
	baseURL  string
	username string
	password string
}

func (s *tokenSource) Token() (*oauth2.Token, error) {
	payload := &bytes.Buffer{}

	if err := json.NewEncoder(payload).Encode(tokenPayload{
		Username: s.username,
		Password: s.password,
	}); err != nil {
		return nil, fmt.Errorf("encode token request body: %w", err)
	}

    u := "https://sdk.iotiliti.cloud/homely/oauth/token"
	req, err := http.NewRequest(http.MethodPost, u, payload)
	if err != nil {
		return nil, fmt.Errorf("created token request: %w", err)
	}

	req.Header.Add("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("execute request: %w", err)
	}
	defer res.Body.Close()

	if res.StatusCode >= 300 {
		return nil, fmt.Errorf("get token: %s", res.Status)
	}

	var r tokenResponse

    if json.NewDecoder(res.Body).Decode(r); err != nil {
		return nil, fmt.Errorf("unmarshal token response: %w", err)
	}

	return &oauth2.Token{
		AccessToken:  r.AccessToken,
		TokenType:    r.TokenType,
		RefreshToken: r.RefreshToken,
		Expiry:       time.Now().Add(time.Duration(r.ExpiresIn)),
	}
}

With this it’s really simple to create an authenticated client like so.

	ts := oauth2.ReuseTokenSource(nil, &tokenSource{
		username: c.Username,
		password: c.Password,
	})

    client := *oauth2.NewClient(context.Background(), ts),

Locations

Now that we can create an authenticated http.Client we can fetch resources from Homely. They have an endpoint /homely/locations to list some basic information about each location you have access to, and the more detailed endpoint /homely/home/<locationID>. The request and response types are shown in the snippet below.

// Location list of loaction
type Location struct {
	Name          string    `json:"name"`
	LocationID    uuid.UUID `json:"locationId"`
	UserID        uuid.UUID `json:"userId"`
	GatewaySerial string    `json:"gatewayserial"`
	PartnerCode   int       `json:"partnerCode"`
}

// Location details from the home endpoint
type LocationDetails struct {
	LocationID         uuid.UUID `json:"locationID"`
	GatewaySerial      string    `json:"gatewayserial"`
	Name               string    `json:"name"`
	AlarmState         string    `json:"alarmState"`
	UserRoleAtLocation string    `json:"userRoleAtLocation"`
	Devices            []Device  `json:"devices"`
}

type Device struct {
	ID           uuid.UUID          `json:"id"`
	Name         string             `json:"name"`
	SerialNumber string             `json:"serialNumber"`
	Location     string             `json:"location"`
	Online       bool               `json:"online"`
	ModelID      uuid.UUID          `json:"modelId"`
	ModelName    string             `json:"modelName"`
	Features     map[string]Feature `json:"features"`
}

type Feature struct {
	States map[string]State `json:"states"`
}

type State struct {
	Value       any       `json:"value"`
	LastUpdated time.Time `json:"lastUpdated"`
}
func (c *Client) Locations(ctx context.Context) ([]Location, error) {
	req, err := c.newRequest(ctx, http.MethodGet, "homely/locations", nil)
	if err != nil {
		return nil, fmt.Errorf("new request: %w", err)
	}

	l := []Location{}
	if err := c.doAndDecode(req, &l); err != nil {
		return nil, err
	}

	return l, nil
}

func (c *Client) LocationDetails(ctx context.Context, locationID uuid.UUID) 
    (LocationDetails, error) {
	var l LocationDetails

	req, err := c.newRequest(ctx, http.MethodGet, 
        fmt.Sprintf("homely/home/%s", locationID), nil)
	if err != nil {
		return l, fmt.Errorf("new request: %w", err)
	}

	if err := c.doAndDecode(req, &l); err != nil {
		return l, err
	}

	return l, nil
}

As you can see from the types, we can use these endpoints to get the state of all the different sensors of all the devices.

Streaming

Getting the current state of sensor is nice, but what we really want is to be notified of changes so that we can use them for automations. Homely’s documentation say that this can be done using a Websocket request on //sdk.iotiliti.cloud. This turned out to have more nuance to it. It is really a Socket.IO endpoint and it lives on wss://sdk.iotility.cloud/socket.io/.

The availablity of Socket.IO clients for Go was surprisingly sparse. I found github.com/googollee/go-socket.io, but couldn’t get it to work properly aginst the Homely server. I have no idea, why I couldn’t get it to work. I would guess it’s possible with more intimate knowledge of how the endpoint is configured and exprience using Socket.IO.

The solution I ended up with was connecting to the endpoint using plain Websockets with github.com/coder/websocket and parsing the messages according to the Engine.IO and Socket.IO protocols. The process of figuring out the small quirks of these protocols was a bit painful as the documentation is sparse.

Engine.IO is a protocol for two way communcation between a client and a server. It can run on top of Websockets or HTTP long-polling. Each session starts with a handshake where the client and server sends a couple of messages agreeing on a session ID and heartbeat parameters. The server will periodically send ping messages to which the client has to resond with pong messages to avoid eviction. These messages are called packets and are utf-8 encoded. Each packet starts with a packet type identifier and then optionally the packet data. A ping packet is as simple as 2 while pong is 3. A packet of the message type is encoded like 4I'm_the_message_data, where 4 is the packet type of a message and I'm_the_message_data is the message data.

Socket.IO builds on top of Engine.IO and allows sending/receiving from multiple “namespaces”. Why these are 2 seperate protocols, I’m not sure I understand. Someone smarter than me probably found it to be a good idea. Anyway, the Socket.IO packets are similar to the Engine.IO packets. Prefixed with a packet type and then the message data. A Socket.IO packet sent over Engine.IO might look like 42{"key": "value"}. This is a Engine.IO packet of the message type carrying a Socket.IO packet of the event type with the JSON object {"key": "value"} as the event.

A Socket.IO session is started by connecting to a namespace. This is done by sending an open packet which has the id 0. Connecting to the namespace /test can be done with a packet looking like this 40/test. If you don’t specify a namespace you’ll be connected to the / namespace.

After going on a several hours long tanget learning a bit about Socket.IO I was finally able to stream some data from the Homely API. I wrote a small client with partial Socket.IO support. For the Socket.IO server to accept the websocket connection, the query parameters EIO=4 and transport=websocket must be set. The API is authenticated using query parameters😱. This is done with the parameter token=Bearer%20<JWT access token>. Lastly, the API takes the query parameter location=<locationID> to designate which location to stream changes from.

func (c *Client) HandleEvents(ctx context.Context, h func(name string, msg string) error) error {
	u, err := url.Parse(c.server)
	if err != nil {
		return fmt.Errorf("invalid url: %w", err)
	}

	q := u.Query()
	q.Set("EIO", EngineIOVersion)
	q.Set("transport", Transport)

	if c.tokenSource != nil {
		t, err := c.tokenSource.Token()
		if err != nil {
			return fmt.Errorf("get token: %w", err)
		}

		q.Set("token", fmt.Sprintf("Bearer %s", t.AccessToken))
	}

	u.RawQuery = q.Encode()

	conn, _, err := websocket.Dial(ctx, u.String(), nil)
	if err != nil {
		return fmt.Errorf("websocket dial: %w", err)
	}

	defer func() {
		if err := conn.CloseNow(); err != nil {
			slog.Error("Errored while closing websocket connection", "error", err)
		}
	}()

	// SocketIO open request
	if err := conn.Write(ctx, websocket.MessageText, []byte("40")); err != nil {
		return fmt.Errorf("socketio connect to namespace: %w", err)
	}

	for {
		_, b, err := conn.Read(ctx)
		if err != nil {
			return fmt.Errorf("read: %w", err)
		}

		s := string(b)

		slog.Debug("Got websocket packet", "packet", s)

		// We only care about EngineIO packets. They start with the message type number
		if len(s) < 1 {
			slog.Debug("Packet has no data")
			continue
		}

		eioType, err := strconv.Atoi(string(s[0]))
		if err != nil {
			slog.Debug("Invalid EngineIO type", "type", s[0])
			continue
		}

		if PacketType(eioType) == EIOPacketTypePing {
			slog.Debug("Got EngineIO Ping, will Pong")
			if err := conn.Write(ctx, websocket.MessageText, []byte("3")); err != nil {
				return fmt.Errorf("eio pong: %w", err)
			}

			slog.Debug("Ponged")
			continue
		}

		if len(s) < 2 {
			// it has no data so we don't care
			slog.Debug("Message is not SocketIO message")
			continue
		}

		sioType, err := strconv.Atoi(string(s[1]))
		if err != nil {
			slog.Debug("Invalid SocketIO type", "type", s[1])
			continue
		}

		if PacketType(sioType) != PacketTypeEvent || len(s) < 3 {
			slog.Debug("Skipping non event SocketIO packet")
			continue
		}

		var values []json.RawMessage
		if err := json.Unmarshal([]byte(s[2:]), &values); err != nil {
			slog.Error("Could not unmarshal SocketIO event", "error", err)
			continue
		}

		if len(values) < 2 {
			slog.Error("Got unexpected number of values from SocketIO event", "values", values)
			continue
		}

		var name string
		if err := json.Unmarshal(values[0], &name); err != nil {
			slog.Error("Failed to unmarshal event name", "error", err)
			continue
		}

		data, err := values[1].MarshalJSON()
		if err != nil {
			slog.Error("Failed to handle message body", "error", err)
			continue
		}

		if err := h(name, string(data)); err != nil {
			return err
		}
	}
}

Using this I can get a stream of changes that look like this:

type Event struct {
	Type string `json:"type"`
	Data EventData `json:"data"`
}

type EventData struct {
	DeviceID       uuid.UUID `json:"deviceId"`
	GatewayID      uuid.UUID `json:"gatewayId"`
	LocationID     uuid.UUID `json:"locationId"`
	ModelID        uuid.UUID `json:"modelId"`
	RootLocationID uuid.UUID `json:"rootLocationId"`
	Changes        []Change  `json:"changes"`
	PartnerCode    int       `json:"partnerCode"`
}

type Change struct {
	Feature     string    `json:"feature"`
	StateName   string    `json:"stateName"`
	Value       any       `json:"value"`
	LastUpdated time.Time `json:"lastUpdated"`
}

From what I can see, in my setup, only the temeperature and network connectivity, values are streamed. One of the main things I wanted yo achieve with the API was to automate my IKEA lights to turn them on when my Yale doorlock noticed that it was opened in the evening. So the lack of log outputs in my terminal when I opened my door was dissapointing.

Conclusion

I spent quite a few hours experimenting with this API. While there is value to be gained from the API, it doesn’t really fit my use case. As this is still in beta I hope that they will continue to add new features and stream changes from more parts of the system.

Socket.IO is a weird protocol. Maybe some of the choices make more sense when you spend more time understanding the different use cases. The documentation of the protocols is alright, but it’s clear that the expectation is for people to use one of the existing client libraries as it’s a bit sparse.