Recently, I wanted to understand how smart home devices actually work. When you scan a QR code and a light appears in your Home app, what’s really going on? When you tap “on,” what bytes travel across your network?

The best way I know to understand something is to build it, so I created a virtual home kit light in Go. And in this tutorial, I’ll walk you through how. We’ll pull back the curtain on smart home protocols so you understand how they work in depth. Let’s dive in.
What we will cover:
what do you need
Before we start building, let’s make sure you have the right setup. This project requires two things:
Go to 1.21 or later: We are using some modern Go features, and the Brotilla/HAP library works best with recent versions. You can check with your version
go version. If you need to upgrade, grab the latest from GO.DEVAn Apple HomeKit environment: This means running iOS 15+ with the Home app. You’ll also want to be on the same Wi-Fi network as the machine your Virtual Lite is running on. HomeKit is completely native, so your phone needs to be able to reach your development machine directly.
One thing that stuck out to me initially is that if you’re running this on a Linux server or inside a container, make sure MDNS traffic isn’t being blocked. Your firewall needs to allow UDP port 5353 (for MDNS discovery) and whatever port your accessory runs on (we’ll use 51826). On a Mac it usually just works.
What exactly is HomeKit?
HomeKit is Apple’s smart home framework. It consists of three things:
A Protocol (HAP) It describes how devices talk to each other,
A security model which encrypts and authenticates everything,
and an ecosystem (Home App, Siri, Automations)
Here, we will focus on the protocol layer. We’re building something that speaks to HAP so well that Apple’s ecosystem accepts it as a true accessory.
Smart home protocol landscape
Before we begin, let’s understand what we are dealing with. There are two protocols at play here:
Homekit Accessory Protocol (HAP): Apple’s original Smart Home protocol from 2014. It runs on your local Wi-Fi network, uses MDN for discovery, and encrypts everything with Vikar 25519 and Chacha 20-Poly 1305. Every HomeKit device you’ve ever used speaks.
The matter: New industry standard supported by Apple, Google, Amazon, and others (2022). The case is actually built on many of the same cryptography as HAP. When Apple added support for Substance, they essentially made HomeKit bilingual, as it can speak both protocols.
Here’s the interesting thing: the physical devices that connect to Apple’s home are still controlled by HomeKit’s infrastructure. The issue is the pairing and discovery layer, but once you have a device in your home, Apple’s ecosystem takes over.
For this project, I am using the HAP protocol directly brutella/hap This library allows us to see what’s going on without an extra abstraction layer of material.
How HomeKit Discovery works
When you run a HomeKit accessory on your network, it doesn’t just sit there waiting. It actively declares using itself mdns (Multicast DNS), also known as Bonjour on Apple platforms.
The accessory broadcasts a service record that looks like this:
_hap._tcp.local.
name: Virtual Light._hap._tcp.local.
port: 51826
txt:
c#=1 // config number (changes trigger rediscovery)
ff=0 // feature flags
id=XX:XX:XX // device ID (like a MAC address)
md=Virtual Light // model name
pv=1.1 // protocol version
s#=1 // state number
sf=1 // status flag (1=not paired, 0=paired)
ci=5 // category (5=lightbulb)
sh=XXXXXX // setup hash
Your iPhone is constantly listening _hap._tcp.local. Broadcast when it watches with one sf=1 (without joints), it appears in the available “Add accessories”.
Let’s see it in code. Here is the minimal server setup:
package main
import (
"context"
"fmt"
"log"
"github.com/brutella/hap"
"github.com/brutella/hap/accessory"
)
func main() {
light := accessory.NewLightbulb(accessory.Info{
Name: "Virtual Light",
Manufacturer: "My Smart Home",
})
server, err := hap.NewServer(hap.NewFsStore("./data"), light.A)
if err != nil {
log.Fatal(err)
}
server.Pin = "00102003"
server.Addr = ":51826"
server.ListenAndServe(context.Background())
}
when ListenAndServe runs, this:
Generates a unique device ID if none exists
Starts listening on port 51826
MDNS registers service records
Awaits connections
At this point, your iPhone can detect it. But what happens when you try to connect it?
Pairing Process: What Happens When You Scan a QR Code?
This is where it gets interesting. Uses HomeKit SRP (Secure Remote Password) Protocol for pairing. This is the same protocol used in things like validating 1 passwords.
When you scan a QR code or enter a PIN, the actual setting is:
Step 1: Pair Setup M1 (iOS → Accessories)
iOS sends: { method: "pair-setup", state: 1 }
Your phone initiates the pairing, telling the accessory “I want to pair with you.”
Step 2: Pair Setup M2 (Devices → iOS)
Accessory sends: {
state: 2,
salt: <16 random bytes>,
public_key:
}
The accessory generates the SRP salt and public key. The PIN code you enter is not sent over the network – instead, it is used locally to obtain an authenticator.
Step 3: Pair Setup M3 (iOS → Accessories)
iOS sends: {
state: 3,
public_key: ,
proof:
}
Your iPhone uses the PIN to calculate its SRP values and sends proof that it knows the PIN.
Step 4: Pair Setup M4 (Device → IOS)
Accessory sends: {
state: 4,
proof:
}
The accessory corroborates the evidence. If the PIN was incorrect, pairing fails here. If true, it sends back its proof.
Step 5-6: Key Exchange
Now both parties have a shared secret derived from SRP. They use this to establish an encrypted channel and exchange long-term ED25519 public keys. These keys are stored permanently. This is why your lights still work after you reboot your router.
The whole dance takes about 22 seconds. after that, sf Changes to records in MDNS 1 to 0 And accessories disappear from “Add Accessories”.
Setup URI: What’s in this QR code?
The QR code contains a URI that encodes everything needed for pairing:
X-HM://0ABCDEFGH1234
^^^^^^^^^^^^
| |
| +-- Setup ID (4 chars)
+---------- Encoded payload (9 chars, base-36)
The payload packs three things into 45 bits:
Category: What type of accessory is it (5 = light bulb, 6 = outlet, 10 = thermostat, and so on)
Flags: How the accessory can pair (2 = supports IP, Wi-Fi pairing, 4 = supports BLE pairing, 6 = supports both)
Pin code As an integer
This allows your iPhone to know which icon to display and which PIN to use, all by scanning a single QR code.
func generateSetupURI(pin, setupID string, category int) string {
var pinInt uint64
for _, c := range pin {
if c >= '0' && c <= '9' {
pinInt = pinInt*10 + uint64(c-'0')
}
}
payload := (uint64(category) << 32) | (2 << 28) | (pinInt & 0x7FFFFFF)
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
encoded := ""
for payload > 0 {
encoded = string(chars(payload%36)) + encoded
payload /= 36
}
for len(encoded) < 9 {
encoded = "0" + encoded
}
return "X-HM://" + encoded + setupID
}
When your iPhone sees the camera X-HM://it knows it’s a homekit code. It decodes the payload to extract the category (so it can display the correct icon) and PIN (so you don’t have to type it). Setup ID helps identify when there are multiple unpaired devices on the network.
What happens when you toggle the light on?
Now for the part I was most interested in. When you tap the Light button in the Home app, what actually travels across your network?
Step 1: Encrypted session
Your iPhone doesn’t just send commands in plain text. Each pairing session uses long-lived keys during pairing to establish the session key. All communications are encrypted with Chacha 20 Poly 1305.
Step 2: Application of HAP
Within the encrypted channel, HomeKit uses a simple HTTP-like protocol. A “turn on” command looks like this:
PUT /characteristics HTTP/1.1
Host: Virtual Light._hap._tcp.local
Content-Type: application/hap+json
{
"characteristics": ({
"aid": 1, // accessory ID
"iid": 10, // instance ID (the "On" characteristic)
"value": true // new state
})
}
Step 3: Instrument response
The accessory processes the request and responds like this:
HTTP/1.1 204 No Content
If something has gone wrong, it will return a Status object with an error code.
In our Go code, we join this with a callback:
light.Lightbulb.On.OnValueRemoteUpdate(func(on bool) {
if on {
fmt.Println("💡 Light ON")
} else {
fmt.Println("💡 Light OFF")
}
})
When this callback fires value Changes to the application. brutella/hap The library handles all decryption, parsing and response generation.
Equipment database model
HomeKit organizes everything into categories:
Accessory (aid=1)
└── Services
├── AccessoryInformation (iid=1)
│ ├── Name (iid=2)
│ ├── Manufacturer (iid=3)
│ ├── Model (iid=4)
│ └── SerialNumber (iid=5)
│
└── Lightbulb (iid=9)
├── On (iid=10) ← boolean
├── Brightness (iid=11) ← int 0-100
└── Hue (iid=12) ← float 0-360
Each feature has one iid (eg ID) When you change the brightness to 75%, the application requests targets aid=1, iid=11, value=75.
This model is why HomeKit accessories are compatible with each other. Regardless of the manufacturer, every light bulb has the same characteristic structure.
Maintaining pair data
When your accessory is paired with a controller (iPhone), it stores:
ED25519 public key of the controller
A Controller ID (36Chars uuid)
Permission Level (Administrator or Regular User)
Accessories also have their own cappy that persists across restarts. If you lose it, all paired controllers become orphaned — that is, they think they’re paired, but accessories can’t recognize them.
As mentioned earlier, we need to save the pairing information so that if the app/device restarts, it can communicate with HomeKit again. You can use a database, but for a single feature, a JSON file works fine. If the process crashes mid-session, you won’t lose the pairing data.
I wrote a simple JSON store to keep everything in one file:
type JSONStore struct {
path string
data map(string)()byte
mu sync.RWMutex
}
func (s *JSONStore) Set(key string, value ()byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data(key) = value
return s.save()
}
func (s *JSONStore) Get(key string) (()byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if v, ok := s.data(key); ok {
return v, nil
}
return nil, fmt.Errorf("key not found: %s", key)
}
The HAP library stores several keys:
uuid– Unique Device Identifierpublic/private– ED25519 cappier*-pairings– Paired controller keys
If you delete this JSON file, the component (our virtual light) forgets all the controllers in its pair. Your iPhone still thinks it’s paired, but the accessory no longer recognizes it — you’ll see “No response” in the Home app. Fix removes the accessory from the Home app and re-adds it again using the QR code.
Event notifications
One thing I didn’t expect is that HomeKit supports push notifications from accessories. When our light state changes (perhaps from a physical switch), we can notify all connected controllers:
light.Lightbulb.On.SetValue(true)
Under the hood, the accessory maintains constant connections with the controllers. When a feature changes, it sends an event message:
EVENT/1.0 200 OK
Content-Type: application/hap+json
{
"characteristics": ({
"aid": 1,
"iid": 10,
"value": true
})
}
Your home app updates in real-time when someone else turns on a light.
Full implementation
Here’s everything:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"github.com/brutella/hap"
"github.com/brutella/hap/accessory"
"github.com/skip2/go-qrcode"
)
const (
pinCode = "00102003"
setupID = "VLTX"
category = 5
dbFile = "data.json"
)
type JSONStore struct {
path string
data map(string)()byte
mu sync.RWMutex
}
func NewJSONStore(path string) *JSONStore {
s := &JSONStore{
path: path,
data: make(map(string)()byte),
}
s.load()
return s
}
func (s *JSONStore) load() {
file, err := os.ReadFile(s.path)
if err != nil {
return
}
json.Unmarshal(file, &s.data)
}
func (s *JSONStore) save() error {
file, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, file, 0644)
}
func (s *JSONStore) Set(key string, value ()byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data(key) = value
return s.save()
}
func (s *JSONStore) Get(key string) (()byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if v, ok := s.data(key); ok {
return v, nil
}
return nil, fmt.Errorf("key not found: %s", key)
}
func (s *JSONStore) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return s.save()
}
func (s *JSONStore) KeysWithSuffix(suffix string) (()string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var keys ()string
for k := range s.data {
if len(k) >= len(suffix) && k(len(k)-len(suffix):) == suffix {
keys = append(keys, k)
}
}
return keys, nil
}
func generateSetupURI(pin, setupID string, category int) string {
var pinInt uint64
for _, c := range pin {
if c >= '0' && c <= '9' {
pinInt = pinInt*10 + uint64(c-'0')
}
}
payload := (uint64(category) << 32) | (2 << 28) | (pinInt & 0x7FFFFFF)
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
encoded := ""
for payload > 0 {
encoded = string(chars(payload%36)) + encoded
payload /= 36
}
for len(encoded) < 9 {
encoded = "0" + encoded
}
return "X-HM://" + encoded + setupID
}
func main() {
light := accessory.NewLightbulb(accessory.Info{
Name: "Virtual Light",
Manufacturer: "My Smart Home",
})
light.Lightbulb.On.OnValueRemoteUpdate(func(on bool) {
if on {
fmt.Println("💡 Light ON")
} else {
fmt.Println("💡 Light OFF")
}
})
store := NewJSONStore(dbFile)
server, err := hap.NewServer(store, light.A)
if err != nil {
log.Fatal(err)
}
server.Pin = pinCode
server.SetupId = setupID
server.Addr = ":51826"
fmt.Println("==============================================")
fmt.Println(" Virtual HomeKit Light")
fmt.Println("==============================================")
fmt.Println("PIN: 001-02-003")
fmt.Println()
setupURI := generateSetupURI(pinCode, setupID, category)
if qr, err := qrcode.New(setupURI, qrcode.Medium); err == nil {
fmt.Println(qr.ToSmallString(false))
}
fmt.Println("Manual: Home app → + → More Options → Virtual Light")
fmt.Printf("Data stored in: %s\n", dbFile)
fmt.Println("==============================================")
ctx, cancel := context.WithCancel(context.Background())
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
cancel()
}()
fmt.Println("Running... (Ctrl+C to stop)")
server.ListenAndServe(ctx)
}
Run it, pair, and watch Terminal while toggling from your phone. Each “💡 light on” is the end of an encrypted request that travels from your phone, through your router, to the process of going.
What did I learn?
Building it cleared up a number of things I was confused about:
HomeKit is completely native. There are no cloud servers involved in controlling the devices – your commands go directly from the phone to the device on your LAN. This is why HomeKit devices work when your internet is down.
The security model is solid. SRP for a pair means that the pin never crosses the network. ED25519 + Chacha 20 for sessions means that even someone sniffing your WiFi only sees encrypted blobs.
Matter does not replace HAP. At least not in Apple’s ecosystem. Matter handles discovery and pairing in the ecosystem, but Apple Home still uses HAP concepts internally.
The protocol is httpish. Once you get past the encryption, it’s just make/receive requests with JSON entities – surprisingly accessible.
Thanks for reading!
Here is the code If you want to experience it yourself. You can try adding a brightness control, or creating a switch instead of a light. The best way to understand the protocol is to speak it 😉