DEV Community

Vladimir Shabunin
Vladimir Shabunin

Posted on • Edited on

Apple HomeKit for Android

This article discusses the task of controlling Apple HomeKit Accessories from Android and the 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.

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

Image description

Apple publishes specifications for non-commercial use, and there are many open-source projects implementing HAP. Some of them include:

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

To control devices through the Home app, the user must complete the 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 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 can control and monitor the accessory, such as turning lights on and off or adjusting the temperature on a smart thermostat.

The first step, scanning, is done 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 scans 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 the pairing step, the user provides a PIN code to authenticate with the accessory device. This authentication is performed using the Secure Remote Password (SRP) protocol.

Once the device has been paired, the Home app stores information about the device and its configuration, allowing it to be easily accessed and controlled in the future. The Home app also enables the user to assign the device to a room or group, simplifying the control of multiple devices.

Technical Details

  1. Discovery

    To start working with accessories, the controller must first discover them. 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. After receiving the response, parse the DNS records (A, SRV, TXT). Then, you can connect to the service using the information received.

  2. Setup Secure Communications

    Two scenarios are possible:

    • Devices are already connected. In this case, proceed to the /pair-verify step.
    • A new connection (pairing) needs to be established. Start with the /pair-setup step.

Apple HomeKit uses Stanford’s Secure Remote Password (SRP) protocol with a password (PIN).

  1. After Verification Once a secure connection is established, accessories and their values can be managed by sending and receiving JSON bodies via an HTTP-like protocol.

/pair-setup

pair-setup diagram

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

Below is a summary of what happens during this stage:

  • M1: The Controller sends a connection request (SRP Start Request).
  • M2: The Accessory initiates a new SRP session, generates the necessary random values and a key pair, and sends the generated public key and salt to the controller (SRP Start Response).
  • M3: The Controller sends a data verification request (SRP Verify Request). At this step, the controller generates its session key pair, prompts the user to enter a PIN code, calculates the SRP session common key and proof (SRP proof), and sends the generated public key and proof to the accessory.
  • M4: The Accessory verifies the controller’s proof and sends its proof in response (SRP Verify Response).
  • M5: The Controller sends an 'Exchange Request'. First, the controller checks the accessory's proof. Then, it generates a long-term key pair (LTPK and LTSK) on the ed25519 curve. Using the session key, the controller derives a new key (HKDF), concatenates it with the controller ID (iOSDevicePairingID) and public key (iOSDeviceLTPK), and signs it with the secret LTSK. The identifier, public key, and signature are written in a TLV message, encrypted with the ChaCha20-Poly1305 algorithm using the session key. This encrypted message is written as a TLV message and sent to the accessory.
  • M6: The Accessory sends an 'Exchange Response'. It extracts the information (iOSDeviceLTPK, iOSDevicePairingID), verifies the signature, signs its identifier and long-term public key, and sends the signed information back to the controller.

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

/pair-verify

This procedure is used each time a secure connection needs to be established. It involves fewer steps (M1-M4) compared to the /pair-setup process.

Both the Controller and the Accessory generate Curve25519 key pairs, exchange public keys, and generate a symmetric shared key, from which the session key is derived. Long-term keys (LTPK and LTSK) are only used to verify signatures.

Upon successful completion of the pair-verify procedure, the TCP connection remains open, and all data transmitted is encrypted with the session key. Effectively, the Keep-Alive HTTP connection is "upgraded" (similar to the WebSocket Upgrade) from this point onward. To interpret the HTTP data, it must first be decrypted.

Implementation

Core

The implementation was developed using Go and the brutella/hap package. While the module does not include a controller implementation and has no plans to add one, adding this functionality was straightforward since all cryptographic procedures are implemented for the server side.

The choice of Go was also motivated by its ability to support GUI development for Android using Golang (e.g., fyne.io, gioui.org).

The final result can be found at hkontrol/hkontroller.

Gioui App

The first approach involved using gioui.

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

Getting started with Gioui was relatively easy, but there were noticeable limitations compared to native Android GUIs. For instance, you can't copy-paste in text inputs, and there is a lack of pull-refresh functionality and other small features. These shortcomings were a source of mild frustration.

Nevertheless, I will definitely use Gioui more in the future, as I believe this project has great potential.

Application Repository

Jetpack Compose

Another option was to use gomobile to create an Android library that could be integrated with a native app. The first attempt failed because Gomobile cannot handle all data types—such as a slice of strings. However, the second attempt succeeded by implementing a wrapper that accepts simple data types (e.g., strings for device names, integers for accessory/service/characteristic IDs) and returns results as JSON strings in the format {"result": any, "error": string}.

Image description

Gomobile supports interfaces; let’s define a 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

Oldest comments (0)