DEV Community

Vladimir Shabunin
Vladimir Shabunin

Posted on • Updated on

Apple HomeKit for Android

This article consider task of controlling Apple HomeKit Accessories from Android and different ways to achieve this.

HAP (HomeKit Accessory Protocol)

Description

Apple HomeKit Accessory Protocol (HAP) is a protocol used by Apple devices to communicate with and control smart home accessories. It enables smart home accessories to be controlled through the Home app on an iOS device or through Siri voice commands. HomeKit supports a wide range of accessory categories including lighting, switches, locks, and thermostats.

So, HomeKit is proprietary protocol created by Apple for Apple iOS devices and for certified manufacturers to create accessories.

Image description

Apple publish specifications for non-commercial use. And there exist many open-source projects implementing HAP. Some of them:

HAP uses secure encrypted communication to ensure the privacy and security of user data and devices.

To be able to control devices through the Home app, user should complete pairing procedure.

The pairing procedure involves a series of steps that allow both devices - controller and accessory - to identify and authenticate each other, establish a secure connection, and exchange information.

HAP pairing involves the following steps for the user:

  • Scanning for devices: The user uses their iOS device to scan for compatible HomeKit devices within range.
  • Selecting a device: The user selects the device they want to pair from the list of available devices.
  • Entering a code: The device will display a unique code that the user must enter into their iOS device to initiate the pairing process.
  • Confirming the pairing: The iOS device and the HomeKit accessory will confirm the pairing and establish a secure connection.
  • Assigning the device: The user assigns the device to a room or group within the Home app, which makes it easier to control multiple devices at once.

Once paired, the iOS device control and monitor the device, such as turning lights on and off or adjusting the temperature on a smart thermostat.

First step, scanning, is done by using Bonjour, Apple's implementation of zero-configuration networking. Bonjour locates devices such as printers, other computers, and the services that those devices offer on a local network using multicast Domain Name System (mDNS) service records.

When the Home app is launched, it performs a scan of the local network to identify compatible HAP accessories. The accessories advertise their presence on the network using Bonjour and the Home app displays a list of discovered devices. The user can then select the device they want to pair with and initiate the pairing process.

When the Home app is launched, it performs a scan of the local network to identify compatible HAP accessories. The accessories advertise their presence on the network using Bonjour and the Home app displays a list of discovered devices. The user can then select the device they want to pair with and initiate the pairing process.

During pairing step user provides a PIN code to get authenticated by accessory device. This authentication done with help of Secure Remote Password(SRP) protocol.

Once the device has been paired, the Home app stores information about the device and its configuration, so that it can be easily accessed and controlled in the future. The Home app also allows the user to assign the device to a room or group, making it easier to control multiple devices at once.

Technical details

  1. Discovery In order to start working with accessories, controller must discover them at first. Devices advertise themselves in accordance with the Multicast DNS and DNS service discovery protocols. You can find a device on the local network by sending a multicast request _hap._tcp.local to 224.0.0.251 , and, after receiving the response, parse the DNS records A, SRV, TXT. After that, you can connect to the service using the information received.
  2. Setup secure communications Two scenarios are possible: the devices are already connected, or the connection (pairing) only needs to be established. In the first case, you need to move to the /pair-verify step, in the case of a new connection, the first step is to perform the /pair-setup step. Apple HomeKit uses Stanford’s Secure Remote Password (SRP) protocol using a password (pin).
  3. after verifying After secure connection was established we can work with accessories and their values by sending and receiving json bodies through kind of HTTP protocol.

/pair-setup

pair-setup diagram

Communication takes place over an established TCP connection. All requests in this step are regular HTTP POST requests with the application/pairing+tlv8 data type and, accordingly, with the TLV -encoded body.

The following is a summary of what happens at this stage:

  • M1: The Controller sends a connection request (SRP Start Request)
  • M2: Accessory initiates a new SRP session, generates the necessary randoms and a key pair. In response, the generated public key and salt are sent to the controller. (SRP Start Response)
  • M3: Controller sends a data verification request (SRP Verify Request). At this step, the controller generates its session key pair, asks the user to enter a pin code, calculates the SRP session common key and proof (SRP proof). The generated public key and proof are sent to the accessory.
  • M4: Accessory verifies the controller proof and sends its proof in response (SRP Verify Response).
  • M5: Controller -> Accessory (‘Exchange Requestʼ). First of all, the controller checks the proof of the accessory. After that, a long-term key pair (LTPK and LTSK) is generated on the ed25519 curve. The controller generates a new key (HKDF) from the session key, concatenates it with the controller ID (iOSDevicePairingID) and its public key (iOSDeviceLTPK), signs it with a secret LTSK. The identifier, public key and signature are written in a TLV message, encrypted with the ChaCha20-Poly1305 algorithm using a common session key. The encrypted message is again written as a TLV message and sent to the accessory.
  • M6: Accessory -> Controller (‘Exchange Responseʼ). Here, the accessory extracts information (iOSDeviceLTPK, iOSDevicePairingID), verifies the signature. Further, similarly, he signs and sends his identifier, long-term public key.

After successfully completing all of the steps M1-M6, the controller and iOS device store each other’s identifiers and public keys (LTPK) for a long time.

/pair-verify

The procedure is used each time to establish a secure connection. Here, there are already fewer steps (M1-M4).

Each participant both the Controller and the Accessory generate Curve25519 key pairs, send public keys to each other and generate a symmetric shared key, from which the session key is formed. Long-term keys (LTPK and LTSK) are only used to verify signatures.

After the successful completion of the pair-verify procedure, the TCP connection remains open and all data inside it is encrypted with the session key. It turns out that the Keep-Alive HTTP connection is “upgraded” (similar to the WebSocket Upgrade) fron now on. In order to get the correct HTTP, the data must first be decrypted.

Implementation

Core

The choice settled on Go and the brutella/hap package. The module does not contain the implementation of the controller and there are no plans to add it. Adding this functionality was simple ehough, given that all cryptographic procedures are implemented for the server side.

The Go solution was also supported by the fact that you can write the graphic part in Golang for Android as well (fyne.io , gioui.org).

Final result located at hkontrol/hkontroller.

Gioui app

The first approach was to use gioui.

Gio is a library for writing cross-platform immediate mode GUI-s in Go. Gio supports all the major platforms: Linux, macOS, Windows, Android, iOS, FreeBSD, OpenBSD and WebAssembly.

Starting with Gioui was quite easy. Still, something was missing in comparison with native Android GUI. Like you can't copy-paste in text inputs, lack of pull-refresh and other small things. And that scratched my mind.

I'll definitely use Gioui more and sure that this project has a great future.

application repo

Jetpack Compose

Another option was to use gomobile to create Android library that can be used with native app. The first attempt failed. Gomobile cannot convert all data types, even slice of strings is not supported. The second try was successful - instead of exporting hkontroller directly, wrapper was implemented that accepts simple data types (strings for device name, integers for accessory/service/characteristic id) and returns result as json string {"result": any, "error": string}.

Image description

Gomobile support interfaces, let's define couple of them:

type CompatibleKontroller interface {
    StartDiscovery() string
    StopDiscovery() string

    GetAllDevices() string
    GetPairedDevices() string
    GetVerifiedDevices() string

    PairSetup(deviceName string, pin string) string
    PairVerify(deviceName string) string
    Unpair(deviceName string) string

    PairSetupAndVerify(deviceName string, pin string) string

    GetDeviceInfo(deviceName string) string
    ListAccessories(deviceName string) string
    GetAccessoryInfo(deviceName string, aid int) string
    FindService(deviceName string, aid int, stype string) string
    FindCharacteristic(deviceName string, aid int, stype string, ctype string) string

    GetAccessoriesReq(deviceName string) string
    GetCharacteristicReq(deviceName string, aid int, iid int) string
    PutCharacteristicReq(deviceName string, aid int, iid int, value string) string

    SubscribeToCharacteristic(deviceName string, aid int, iid int) string
    UnsubscribeFromCharacteristic(deviceName string, aid int, iid int) string

    // SubscribeToAllEvents supposed to be used instead of other subscribe methods.
    // There is no unsubscribe method because subscriptions should not persist across session.
    // So, channels should close automatically on device lost/unpaired event.
    SubscribeToAllEvents(deviceName string) string
}
Enter fullscreen mode Exit fullscreen mode
type MobileReceiver interface {
    OnDiscovery(string)
    OnLost(string)
    OnPaired(string)
    OnUnpaired(string)
    OnVerified(string)
    OnClose(string)
    OnCharacteristic(string)
}
Enter fullscreen mode Exit fullscreen mode

The first one, CompatibleKontroller implementation should be done on backend side, another one - MobileReceiver - in Java/Kotlin and methods of this interface are called when CompatibleKontroller receive some event.

// NewCompatibleController returns wrapper aroung hkontroller.Controller.
// NewCompatibleController(name, configDir, receiver)
func NewCompatibleController(name string, configDir string, receiver MobileReceiver) CompatibleKontroller {
    store := hkontroller.NewFsStore(path.Join(configDir, "hkontroller"))
    controller, err := hkontroller.NewController(store, name)
    if err != nil {
        panic(err)
    }

    err = controller.LoadPairings()
    if err != nil {
        panic(err)
    }

    return &hkWrapper{
        controller: controller,
        receiver:   receiver,
    }
}
Enter fullscreen mode Exit fullscreen mode

For example, how we react on characteristic value update:

func (k *hkWrapper) onEvent(deviceName string, e emitter.Event) {
    if len(e.Args) != 3 {
        return
    }

    ev := characteristicEvent{
        Dev: deviceName,
        Aid: e.Args[0],
        Iid: e.Args[1],
        Val: e.Args[2],
    }

    jj, err := json.Marshal(ev)
    if err != nil {
        fmt.Println("cannot marshal characteristic event: %s", err.Error())
        return
    }

    k.receiver.OnCharacteristic(string(jj))
}
Enter fullscreen mode Exit fullscreen mode

On Kotlin side we create singleton object HkSdk that implements MobileReceiver interface:

object HkSdk : MobileReceiver {
    var running = false
    var controller: CompatibleKontroller? = null

    ....
    ....

    init {
        println("HkSdk object created")
    }

    fun configure(name: String = "app5", configDir: String) {
        if (controller == null) {
            controller = hkmobile.Hkmobile.newCompatibleController(name, configDir, this)
            println("initialized controller")
        }
    }

    fun start() {
        if (running) {
            return
        }
        controller?.startDiscovery()
        println("mdns discovery started")
        running = true
    }

    @OptIn(ExperimentalStdlibApi::class)
    override fun onCharacteristic(p0: String?) {
        runBlocking {
            if (p0 != null) {
                try {
                    val jsonAdapter: JsonAdapter<CharacteristicEvent> = moshi.adapter()
                    val event = jsonAdapter.fromJson(p0)
                    if (event != null) {
                        _characteristicEvents.emit(event)
                    }
                } catch (e: Exception) {
                    println(e.message)
                }
            }
        }
    }

Enter fullscreen mode Exit fullscreen mode

This object is created once at start when we are able to retrieve data directory path (to use as pairing store) and used elsewhere in application to manage homekit devices.

I can't call application created with Jetpack Compose completely native, still it has it's own pros - rich standard library, big community. The con is that you have to communicate with hkontroller library through additional layer and that can impact performance.

application repository

Latest comments (0)