Skip to content

Flic 2 Protocol Specification

Emill edited this page Dec 5, 2024 · 7 revisions

Flic Logo Black

Flic 2 Protocol Specification

The Flic 2 protocol uses the GATT protocol over Bluetooth Low Energy (BLE) to communicate. It has been designed in such a way so that multiple applications (apps) running on the same device can communicate with the button concurrently.

This specification assumes that the reader is already familiar with BLE, as well as the Flic 2 Technical Overview and Terminology document.

This specification uses little endian encoding for all integers, byte orderings and bit orderings, unless stated otherwise.

Terminology and Notations

  • RFU: Reserved for Future Use. When writing, this must be set to 0. Must be ignored upon read.
  • Random: When this specification mentions that random data must be generated, a cryptographically secure random number generator must be used. Never use rand() from the C standard library or similar. Instead you shall use /dev/urandom, java.security.SecureRandom, NRF_RNG or whatever CSPRNG your platform supports.
  • Shall, Must: when these words are used it means a strict requirement.
  • Should: when the word "should" is used it means a recommended action.
  • Bytes can be written as 'A' - 'Z', which should be treated as the ASCII byte for the specified character.
  • Button events: up, down, click, double click and hold.

Pairing

Even though the button supports standard BLE pairing and bonding (the "Just Works" variant), the security that Flic 2 uses does not depend on BLE security. Instead Flic 2 security is implemented at the application layer. When pairing the button (requires it to be in Public mode), a pairing identifier (32-bit) and pairing key (128-bit) is created. This pair should be stored persistently so communication can continue after a re-connection. Each app will get its own pairing. However, please note that the use of standard BLE pairing and bonding is encouraged, if available, as an extra layer of security. Bonding can also comes with some other, platform dependent, benefits, such as service caching which can speed up connection establishment.

Advertising

When a button is pressed, it will start to transmit BLE advertisements. The advertised data is different depending on what privacy mode the button is in at that time. The advertising data specified down below specify the currently used fields. More fields might be added in the future.

For further information about data fields, see the Core Specification Supplement document from Bluetooth SIG.

Private Mode

While in this mode, the button will only advertise when the button is not connected to any device. The only advertised data in each ADV_IND packet is of the Flags data type, where the bits LE General Discoverable Mode and BR/EDR Not Supported are set. The SCAN_RSP data is empty.

Public Mode

When the button enters Public mode, it will always start to advertise, regardless if it's connected to a device or not. If it's not connected, packets are of the type ADV_IND and SCAN_RSP. If it's already connected, packets are of the type ADV_SCAN_IND and SCAN_RSP.

The ADV_IND or ADV_SCAN_IND packet contains the following fields:

  1. Flags. Same as in Private mode.

  2. Complete List of 128-bit Service UUIDs containing one UUID 00420000-8F59-4420-870D-84F3B617E493.

  3. Complete Local Name. This consists of 8 characters, starting with F2. The next two characters contain the firmware version, written as a decimal string. The last four characters contain the 24 least significant bits of the Bluetooth Device Address of the button, written as a big-endian byte string, encoded as Base 64 Encoding with URL and Filename Safe Alphabet. For example, a button with firmware version 7 with Bluetooth Device Address XX:XX:XX:76:42:06 will have the name F207dkIG.

The SCAN_RSP packet contains the following fields:

  1. Manufacturer Specific Data. This starts with the company identifier 0x030f encoded as the two bytes 0f 03. Then follows 02 XX XX XX YY, where XX XX XX is the little-endian byte encoding of the 24 most significant bits of the Bluetooth Device Address, and YY is a bitfield where bit 0 (least significant) indicates the Address Type of the Bluetooth Device Address (0 for Public and 1 for Static Random) and bit 1 indicates if the button already has an established connected to any device (1) or not (0). Bit 1 will be set to 0 if ADV_IND packets are used and 1 if ADV_SCAN_IND packets are used. The rest of the bits are RFU. The length of the manufacturer specific data might be enlarged in the future. In this case, treat those extra bytes as RFU.

Coded PHY

By default, the button will also advertise on the Coded PHY. It uses Connectable Undirected advertising if not currently connected and Non-Connectable and Non-Scannable Undirected with auxiliary packet if connected. The Adv Data in the auxiliary packet will be a concatenation of all the data fields specified above. If you don't wish to use Coded PHY then you can disable it, see SetAdvParametersRequest.

GATT Services

The button implements the Bluetooth SIG-specified GAP and GATT services. Notably, it implements the Device Name Characteristic (read only), which will have the format Flic XX00-X00000 where the second part (after space) contains the serial number of the button.

The button also has a GATT service with UUID 00420000-8F59-4420-870D-84F3B617E493 which is being used for all Flic 2-specific communication. It contains two characteristics:

  1. 00420001-8F59-4420-870D-84F3B617E493. ATT Value Handle: 0x0010. Properties: Write Without Response.

  2. 00420002-8F59-4420-870D-84F3B617E493. ATT Value Handle: 0x0012. Properties: Notify.

Data is sent to the button using the first characteristic. The button sends data on the second characteristic.

The button supports an ATT MTU of 140 bytes, and hence up to 137 bytes can be sent on a characteristic (since 3 bytes are used for the ATT header). To make use of a larger MTU than the default of 23, the GATT procedure "Exchange MTU" must first be performed.

Packet Format

The packet format is the same in both directions. It's a binary format of the following components:

  1. One byte (byte #0) with the following fields:
    1. Bit 0-4: Logical connection id (0 for connection-less packets). Referred to as connId.
    2. Bit 5: Boolean indicating newly assigned Logical connection id (newlyAssigned). Always 0 in direction to button.
    3. Bit 6: Indicates multiple packets in one GATT transaction. If bit 6 is 1 and bit 7 is 0, insert a byte between byte #0 and byte #1 containing the length of this packet (including data and signature, but not including byte #0 and the length byte). After this packet (in the same GATT value), a new packet starts with a new byte #0. This feature is optional to implement.
    4. Bit 7: Fragment bit (0=last fragment in packet, 1=not last fragment in packet).
  2. One opcode byte (byte #1).
  3. Data.
  4. Five signature bytes (not always present). Described further below.

The fragmentation feature (bit 7) is used to support devices that don't support a large ATT MTU. If the packet to be sent will not fit in one GATT transaction, first concatenate the opcode, data and signature (if present). Then split that packet into multiple fragments, and prepend a copy of byte #0 to all fragments. Then set bit 7 in byte #0 of all fragments to 1 except the last fragment, where it shall be set to 0. The fragments are then sent directly after each other, using multiple Write Without Responses or Notifications.

A packet must not be larger than 129 bytes (after reassembly), including all components.

All major operating systems support multiple apps to be connected to the same BLE peripheral. However, the GATT specification itself does not have any concept of multiple apps or GATT clients. It just mentions a single GATT client. Operating systems deal with this by allowing any app to send GATT requests, and delivering the response only to the app that sent the request. Notifications, however, are broadcasted to every app interested in notifications. Therefore each GATT value sent includes a "logical connection id" to identify the app. An app that receives a packet with an incorrect logical connection id should drop that packet.

Packet Opcodes and Types

Each opcode has an associated struct that describe the contents. The set of opcodes are different depending on the direction of the packet (to button and from button). The notation used to describe the structs in this document is C notation for little-endian systems. The data type bool uses 1 byte and the value shall either be 0 or 1. All structs are "packed", which means no padding between fields. With GCC this means #define PACKED __attribute__ ((packed)). The first byte of the struct is always the opcode (byte #1).

An example description follows:

#define OPCODE_TO_FLIC_SET_CONNECTION_PARAMETERS_IND 12
struct SetConnectionParametersInd {
    uint8_t opcode;
    uint16_t intv_min, intv_max, latency, timeout;
} PACKED;

When receiving a packet, the length of the incoming packet data shall be checked and verified to be at least as large as the struct. Otherwise the packet shall be dropped. Some packets are dynamic in length and will end with a field of a variable-length array, such as char name[];. Note that all packets that are not dynamic in length might be extended in the future, and hence excessive bytes must be ignored.

There are four types of opcodes: requests, responses, notifications and indications. The opcodes are suffixed with _REQUEST, _RESPONSE, _NOTIFICATION and _IND, respectively, in their names which describe their type.

Unless otherwise specified, when a request is received by either device, a matching response shall be sent. At most two requests may be sent to the button concurrently for a given logical connection id, until responses are received. This means the difference between the number of requests that have been sent and the number of responses that have been received should never be greater than two. Some queueing mechanism can be used to make sure this condition is fulfilled.

Notifications and indications are one-way packets that don't have a corresponding response, so the Flic will not put a restriction on how many concurrent notifications or indications that are sent. However, a few platforms, such as iOS, have internal buffers that restricts this flow.

Packet Signature

Once a session has successfully been established (defined below), a 5-byte long signature will be added to each succeeding packed to ensure authenticity. The protocol uses the 16-round variant of Chaskey called Chaskey-LTS. Chaskey takes as input a 16-byte key and an arbitrary long message and produces a signature.

The key used in this protocol when calculating the signature is the session key (defined further down), which is unique to each session. It is derived from a random value (generated by the each device) and the pairing key.

The message used in this protocol when calculating the signature is composed of two parts concatenated. The first part is a 64-bit packet counter followed by 64-bit direction value (either 0 for the direction from button, or 1 for the direction to button). The second part consists of the opcode byte concatenated by the data bytes (i.e. the second part of the message is the struct contents).

Even though Chaskey produces a 16-byte long signature, it's always truncated to the first 5 bytes to save space in the packets sent over the air.

There are two packet counters, one per direction. The first signed packet has counter value 0. The packet counter is incremented by 1 after a packet is processed.

Session

This protocol defines a "session". A session is a collection of state variables and is bound to a logical connection id (or bound to nothing if it has not received an id yet). Just as a TCP session runs over IP, a Flic 2 session runs over BLE. A Flic 2 session however must always be terminated if the BLE connection is terminated.

It's recommended to have a session object that contains everything associated with the session. If a new session needs to be set up for a button, always destroy the previous session object (if there exists one) and create a new one.

State Enumeration

In order to make sure incoming packets of different opcodes are only handled in the correct state, a few state enumeration values are defined:

  • STATE_WAIT_FULL_VERIFY1
  • STATE_WAIT_FULL_VERIFY2
  • STATE_WAIT_QUICK_VERIFY
  • STATE_SESSION_ESTABLISHED
  • STATE_WAIT_FULL_VERIFY1_TEST_UNPAIRED
  • STATE_WAIT_TEST_IF_REALLY_UNPAIRED_RESPONSE
  • STATE_FAILED

Starting a New Session - Full Verify

When the app pairs a new button, it performs a BLE scan and detects that the button is in Public mode. It then connects to the button and prepares the GATT communication. The following packet is then sent to the button, with connId set to 0, and no signature:

#define OPCODE_TO_FLIC_FULL_VERIFY_REQUEST_1 0
struct FullVerifyRequest1 {
    uint8_t opcode;
    uint32_t tmp_id;
} PACKED;

Here tmp_id shall be set to a newly generated random number and stored within the session. After the packet has been sent, the session state is set to STATE_WAIT_FULL_VERIFY1.

In the STATE_WAIT_FULL_VERIFY1 state, no other than the following two specified incoming packets must be processed.

#define OPCODE_FROM_FLIC_NO_LOGICAL_CONNECTION_SLOTS_IND 2
struct NoLogicalConnectionSlotsInd {
    uint8_t opcode;
    uint32_t tmp_ids[];
} PACKED;

If a NoLogicalConnectionSlotsInd packet is received with connId set to 0, where tmp_ids contains the tmp_id associated with the session, the session must be immediately terminated and the state must be set to STATE_FAILED. This means the maximum number of apps the button can handle are already connected. At this time the user should be informed of this so the user can quit some other apps using the button. Then the pairing procedure can be started over from the beginning in order to try again.

#define OPCODE_FROM_FLIC_FULL_VERIFY_RESPONSE_1 0
struct FullVerifyResponse1 {
    uint8_t opcode;
    uint32_t tmp_id;
    uint8_t signature[64];
    uint8_t address[6];
    uint8_t address_type;
    uint8_t ecdh_public_key[32];
    uint8_t random_bytes[8];
    uint8_t link_is_encrypted: 1;
    uint8_t is_in_public_mode: 1;
    uint8_t has_bond_info: 1;
    uint8_t rfu: 5;
} PACKED;

When this packet is received with newlyAssigned set to 1, tmp_id must be checked against the tmp_id associated with the session. If it matches, connId contains the logical connection id that the session will now use for all subsequent packets. The packet content must no inspected and verified to be correct in the following way.

The link_is_encrypted and has_bond_info field refers to Bluetooth (BLE) Pairing and Bonding only and is thus not directly related to the Flic 2 protocol.

The address field contains the 48-bit Bluetooth Device Address of the button in little endian byte order, which must be checked against the address of the connected button indicated by the Bluetooth stack. The address_type must also be verified. If the Bluetooth stack does not expose the address or the address type, then the address and address type can be found by examining the SCAN_RSP data and the advertised name, from when it advertised previously. If the Bluetooth stack neither provides the address nor the advertising data, then the address and address type indicated by this struct must be used onwards as the identifier of the button.

An Ed25519 verification must also be performed to verify the genuineness of the buttons. The 39-byte Ed25519 message is produced by concatenating the address, address_type and ecdh_public_key fields. The Ed25519 public key (ecdh_public_key has a completely different meaning) that must be used is d33f2440dd54b31b2e1dcf40132efa41d8f8a7474168df4008f5a95fb3b0d022. The Ed25519 signature that shall be verified is contained in the 256-bit long signature field. However, the two bits 128-129 (0-indexed) will all be initially set to 0 in the signature field, i.e. signature[32] & 0x03 is 0. All four combinations of setting these two bits to different values must be tested using the Ed25519 verification algorithm, and only one or zero will pass. If zero combinations pass the Ed25519 verification, the genuineness of the button is invalid. If one combination passes, save signature[32] & 0x03 to the variable sigBits as a single byte. If sigBits is set to the wrong value, the button will fail the verification and deny further pairing attempts during a limited amount of time.

If either the address verifciation or the Ed25519 verification failed, the session state must be set to STATE_INVALID, the button should be disconnected and the error should be reported to the user.

Otherwise, the pairing attempt can continue. First some cryptographic operations need to be performed.

First a Curve25519/X25519 shared secret must be generated. The ecdh_public_key field contains the button's public Curve25519 key. A new temporary 32-byte random Curve25519 secret key must now be generated (it is important not to reuse a previous one). Use your secret key and the Curve25519-defined base point with the X25519 function to create your public key clientPublicKey, and the X25519 function again with your secret key and the button's public key to generate the 32-byte long shared secret curve25519SharedSecret. Also generate 8 new random bytes clientRandomBytes.

Now create fullVerifySecret by using the SHA-256 hash function on the concatenation of curve25519SharedSecret, sigBits, random_bytes (from the FullVerifyResponse1 packet), clientRandomBytes and a byte containing 0. Instantiate an implementation of HMAC-SHA-256 using fullVerifySecret as key and save it in the session object as fullVerifyHmac.

Now create verifier by applying fullVerifyHmac with the 2-byte message 'A', 'T', and truncating the result to the first 16 bytes.

Similarly sessionKey is created by applying fullVerifyHmac with the 2-byte message 'S', 'K', and truncating the result to the first 16 bytes. The sessionKey is stored to the session object and will be the key used for Chaskey signing.

#define OPCODE_TO_FLIC_FULL_VERIFY_REQUEST_2 2
struct FullVerifyRequest2 {
    uint8_t opcode;
    uint8_t ecdh_public_key[32];
    uint8_t random_bytes[8];
    uint8_t rfu;
    uint8_t verifier[16];
} PACKED;

The above packet is now sent (without a signature), where ecdh_public_key contains clientPublicKey, random_bytes contains clientRandomBytes, rfu contains 0, verifier contains verifier. connId shall from now on always be set to the assigned id. When receiving a packet, connId must match the assigned id, or the packet shall be dropped. When this packet has been sent, the state is set to STATE_WAIT_FULL_VERIFY2.

Another option than sending the above packet, the verification attempt may be aborted for any reason by sending this packet, allowing the button to release the resources held for this session:

#define OPCODE_TO_FLIC_FULL_VERIFY_ABORT_IND 3
struct FullVerifyAbortInd {
    uint8_t opcode;
} PACKED;

If that packet is sent, the session must be terminated and the state must be set to STATE_FAILED.

In the STATE_WAIT_FULL_VERIFY2 state, no other than the following two specified incoming packets must be processed.

#define OPCODE_FROM_FLIC_FULL_VERIFY_FAIL_RESPONSE 3
struct FullVerifyFailResponse {
    uint8_t opcode;
    uint8_t reason;
} PACKED;
enum FullVerifyFailReason {
    FULL_VERIFY_FAIL_REASON_INVALID_VERIFIER,
    FULL_VERIFY_FAIL_REASON_NOT_IN_PUBLIC_MODE,
};

This packet is sent without signature and indicates failure. The reason is one of the FullVerifyFailReason values (but more might be added in the future). If it indicates "not in public mode" even though it advertised it was in public mode, it probably switched to private mode in the time window from when the advertisement packet was received and the FullVerifyRequest2 packet was sent. If it indicates "invalid verifier" then your cryptographic calculations were incorrect.

#define OPCODE_FROM_FLIC_FULL_VERIFY_RESPONSE_2 1
struct FullVerifyResponse2 {
    uint8_t opcode;
    uint8_t app_credentials_match: 1;
    uint8_t cares_about_app_credentials: 1;
    uint8_t rfu: 6;
    uint8_t button_uuid[16];
    uint8_t name_len;
    char name[23];
    uint32_t firmware_version;
    uint16_t battery_level;
    char serial_number[11];
} PACKED;

If the verification succeeded, the above packet will be sent, with the 5-byte signature after the end according to the "Packet format" section. If the signature is not matching on this packet or on subsequent packets, the session must be terminated and the state must be set to STATE_FAILED.

If the app_credentials_match field is set to 0, the session must be terminated and the state must be set to STATE_FAILED.

Otherwise, the pairing attempt succeeded and the pairing id and pairing key can now be derived and used for future re-connections. Use fullVerifyHmac from earlier with the 2-byte message 'P', 'K' to generate pk. The first 4 bytes of pk contains the 32-bit pairing id and the following 16 bytes of pk contains the 128-bit pairing key. This pair should be stored to some persistent database.

The remaining fields contain information about the button you might want to save:

  • The button uuid is a 16-byte UUID, stored in big endian byte order.
  • The name is encoded as an UTF-8 string. Only use the first name_len bytes in the name field. The name is a user-defined name that is initally empty when the button is new.
  • Firmware version is an integer.
  • Battery level can be converted to a floating point Voltage value using the formula (battery_level * 3.6 / 1024.0).
  • Serial number is an ASCII string of the format XX00-X00000.

The session state is now set to STATE_SESSION_ESTABLISHED. See "Init button events" below how to continue.

Starting a New Session - Quick Verify

When a pairing id and pairing key exists for a button, the "quick verify" method shall be used to start a new session.

#define OPCODE_TO_FLIC_QUICK_VERIFY_REQUEST 5
struct QuickVerifyRequest {
    uint8_t opcode;
    uint8_t random_client_bytes[7];
    uint8_t rfu: 8;
    uint32_t tmp_id;
    uint32_t pairing_identifier;
} PACKED;

To start quick verify, fill random_client_bytes with a new generated random value, set rfu to 0, tmp_id with another new generated random value and pairing_identifier to the stored pairing id. Store the random values in the session object as well. Send the packet with connId set to 0 and no signature. The session state is then set to STATE_WAIT_QUICK_VERIFY.

In the STATE_WAIT_QUICK_VERIFY state, no other than the following three specified incoming packets must be processed.

A NoLogicalConnectionSlotsInd can be received in the same way as during the full verify procedure. The only difference is that instead of aborting the pairing attempt, retrying again with a new session after 30 seconds is probably a better idea.

#define OPCODE_FROM_FLIC_QUICK_VERIFY_NEGATIVE_RESPONSE 6
struct QuickVerifyNegativeResponse {
    uint8_t opcode;
    uint32_t tmp_id;
} PACKED;

If the above packet is received connId set to 0 and no signature, where tmp_id matches the one associated with the session, it means the pairing id sent in the request is unknown to the button. Unless an incorrect pairing id was sent, it usually means the button has been factory reset or the button has deleted old pairings to fit new ones. In this case, the "Test if really unpaired" procedure defined below should be used, otherwise the session must be terminated and the state set to STATE_FAILED.

#define OPCODE_FROM_FLIC_QUICK_VERIFY_RESPONSE 8
struct QuickVerifyResponse {
    uint8_t opcode;
    uint8_t random_button_bytes[8];
    uint32_t tmp_id;
    uint8_t link_is_encrypted: 1;
    uint8_t has_bond_info: 1;
    uint8_t padding: 6;
} PACKED;

This packet will have the newlyAssigned bit set to 1 and the connId to the assigned id. If the tmp_id does not match the session associated value, drop this packet. This connId must be used on all subsequent packets for this session. If a packet is received with a different connId, it must be dropped. This packet is signed, but before the signature can be validated the session key has to be derived first.

First a 16-byte message is produced by concatenating random_client_bytes, one byte containing 0 and random_button_bytes. The session key is now created by using Chaskey-LTS with the stored pairing key as key and this 16-byte message as message. The resulting 16 bytes make up the session key. This QuickVerifyResponse packet is the first packet that needs signature verification. If the verification fails, the session must be terminated and the state must be set to STATE_FAILED.

The session state is now set to STATE_SESSION_ESTABLISHED. See "Init button events" below how to continue.

Test If a Pairing Has Really Been Unpaired

If a QuickVerifyNegativeResponse is received, the following procedure should be followed to verify that the pairing really has been unpaired. The QuickVerifyNegativeResponse alone cannot be trusted, since a hacker could easily spoof a device and send this response.

First, a FullVerifyRequest1 packet shall be sent just as in the full verify procedure. But instead of using the STATE_WAIT_FULL_VERIFY1 state while waiting for the response, the state STATE_WAIT_FULL_VERIFY1_TEST_UNPAIRED shall be used. The FullVerifyResponse1 packet must be validated as usual, but instead of sending a FullVerifyRequest2, this packet shall be sent instead:

#define OPCODE_TO_FLIC_TEST_IF_REALLY_UNPAIRED_REQUEST 4
struct TestIfReallyUnpairedRequest {
    uint8_t opcode;
    uint8_t ecdh_public_key[32];
    uint8_t random_bytes[8];
    uint32_t pairing_identifier;
    uint8_t pairing_token[16];
} PACKED;

The ecdh_public_key and random_bytes fields shall be set in the same way as for the FullVerifyRequest2 packet. The pairing_identifier field shall be set to the pairing identifier that is to be tested. The pairing_token field shall be set to the first 16 bytes of the result of applying fullVerifyHmac with the 22-byte message 'P', 'T', pairing id, pairing key (concatenated). The connId shall be set to the same value as in the FullVerifyResponse1 packet and the packet shall be unsigned. When the packet has been sent, the session state shall be set to STATE_WAIT_TEST_IF_REALLY_UNPAIRED_RESPONSE.

When the state is STATE_WAIT_TEST_IF_REALLY_UNPAIRED_RESPONSE, only this packet must be processed for the session:

#define OPCODE_FROM_FLIC_TEST_IF_REALLY_UNPAIRED_RESPONSE 4
struct TestIfReallyUnpairedResponse {
    uint8_t opcode;
    uint8_t result[16];
} PACKED;

The connId shall be verified and the packet is unsigned.

Now apply fullVerifyHmac with the 18 byte message 'N', 'E', pairing_token (concatenated), where pairing_token is the value sent in the previous request. The first 16 bytes of this result shall now be compared with the result field. If the values match, it can be trusted that the pairing has really been removed from the button. In this case the button should be removed from the persistent storage and the user be notified.

In any case, the session must now be terminated and the state must be set to STATE_FAILED.

When a Session Has Been Established

When the session has been established and the state has been set to STATE_SESSION_ESTABLISHED, the session is open for communication. At this point, every packet's connId must match the session's associated logical connection id (otherwise the packet belongs to a different session or is connection-less). All packets must also be signed and incoming packets must be verified using Chaskey-LTS. If a signature is invalid, the session must be terminated and the state must be set to STATE_FAILED. An attempt to start a new session should start again after five seconds.

Init Button Events

An init packet must be sent to the button before it will send any button events.

#define OPCODE_TO_FLIC_INIT_BUTTON_EVENTS_LIGHT_REQUEST 23
struct InitButtonEventsLightRequest {
    uint8_t opcode;
    uint32_t event_count;
    uint32_t boot_id;
    uint64_t auto_disconnect_time: 9;
    uint64_t max_queued_packets: 5;
    uint64_t max_queued_packets_age: 20;
    uint64_t rfu: 6;
} PACKED;

The request packet includes two fields event_count and boot_id that should be set from the persistent storage that are associated with the button. They are used to inform the button of which button events have already been received during an earlier session so they are not sent again. Initially and on the first connection those shall be set to 0 since they are unknown. Every time the button boots, its internal event_count is set to 0 and boot_id to a random value. As the user interacts with the button (click, release etc.), the event_count is increased.

The max_queued_packets and max_queued_packets_age can be used to tell the button to drop button events that are considered too old at the time the init packet is received by the button, and prevent them from being sent. max_queued_packets can be set to a value between 0 and 30 to only send the latest max_queued_packets packets (31 means no limit) and max_queued_packets_age can be set to a value between 0 and 0xffffe to only sent packets that are at most max_queued_packets_age seconds old (0xfffff means no limit).

The auto_disconnect_time parameter can be used to let the button automatically disconnect the BLE link after the specified number of seconds of inactivity has passed (511 disables this feature). This can be used to reduce battery consumption of the button, in case the button is used infrequently, at the expense of higher latency when the button is later pressed since a new connection and session must be established. If this feature is used, it's recommended to set the parameter to at least 40 seconds.

After the above packet is received by the button, it will send one of the following two packets in response.

#define OPCODE_FROM_FLIC_INIT_BUTTON_EVENTS_RESPONSE_WITH_BOOT_ID 10
struct InitButtonEventsResponseWithBootId {
    uint8_t opcode;
    uint64_t has_queued_events: 1;
    uint64_t timestamp: 47;
    uint32_t event_count;
    uint32_t boot_id;
} PACKED;
#define OPCODE_FROM_FLIC_INIT_BUTTON_EVENTS_RESPONSE_WITHOUT_BOOT_ID 11
struct InitButtonEventsResponseWithoutBootId {
    uint8_t opcode;
    uint64_t has_queued_events: 1;
    uint64_t timestamp: 47;
    uint32_t event_count;
} PACKED;

If the has_queued_events bit is 1, it means there were button events queued that should be processed and delivered to the user. These button event packets will be sent as soon as possible after this packet is sent. The timestamp indicates how much time has passed since the button was booted, in units of 1/32768 seconds (around 30.5 microseconds).

The event_count and boot_id (if present) parameters should be stored in persistent storage associated with the button.

Processing Button Events

When the user interacts with the button, the button will send button events. Button events can also be sent after the init response packet that represent events that got queued in the button's RAM before the init packet was sent.

Apart from button up and button down events, the button also has functionality for differentiating single click, double click and hold events. More specific, the protocol is designed to work for apps that want to differentiate between button events in one of the following four use cases:

  • Button up / button down.
  • Click / hold.
  • Single click / double click.
  • Single click / double click / hold.

A single click is defined as a down event followed by an up event such there is no more down event 0.5 seconds after the first down event.

A double click will occur when the button is double clicked in such a way that the time span between the two corresponding down events is less than 0.5 seconds. The event will be emitted at the time of the second release. Each down event cannot be part of more than one double click event.

A hold event will occur when the button has been held down for at least one second. For the fourth use case above, a short click immediately followed by a long click will be treated as a double click and not as a hold.

The button sends different encoded events so that all four ways above can be implemented.

Notably, the button can send "down", "hold", "up", "single click timeout" events. An event sent by the button is contained in the following struct.

struct ButtonEventNotificationItem {
    uint64_t timestamp: 48;
    uint64_t event_encoded: 4;
    uint64_t was_queued: 1;
    uint64_t was_queued_last: 1;
    uint64_t rfu: 2;
} PACKED;

The timestamp represent the time the event occurred, since the button was booted, in units of 1/32768 seconds. The was_queued bit indicates if this event was queued or if it occurred after the init packet. The was_queued_last bit is set to indicate that this is the final queued event.

The event_encoded field works in the following way. The first two bits (least significant) shall be read to set the initial value of type. For clarification the different values have the following meanings:

  • 0: up
  • 1: down
  • 2: single click timeout
  • 3: hold

The boolean variables wasHold, singleClick, doubleClick, nextUpWillBeDoubleClick shall then be set to false.

If bit 3 of event_encoded is 1, then set these variables according to the following formulas:

  • type: 0 (this overwrites the value set above)
  • wasHold: (event_encoded & 4) != 0
  • singleClick: (event_encoded & 2) != 0 && (event_encoded & 1) == 0
  • doubleClick: (event_encoded & 2) != 0 && (event_encoded & 1) != 0

otherwise if event_encoded is 7:

  • nextUpWillBeDoubleClick: true

We can now parse the event according to the four uses cases given the above variables:

Button Up / Button Down
  • Button up: type == 0
  • Button down: type == 1
Click / Hold
  • Click: type == 0 && !wasHold
  • Hold: type == 3
Single Click / Double Click
  • Single click: (type == 0 && singleClick) || (type == 2)
  • Double click: type == 0 && doubleClick
Single Click / Double Click / Hold
  • Single click: (type == 0 && !wasHold && singleClick) || (type == 2)
  • Double click: type == 0 && doubleClick
  • Hold: type == 3 && !nextUpWillBeDoubleClick

If a condition is true, an event should be emitted to the user if that use case is desired.

Furthermore, if (type == 0 && (singleClick || doubleClick)) || type == 2 is true, then an acknowledgement packet (AckButtonEventsInd) shall be sent. More on that later on.

When the button has button events to send, it will send one or more button event notification packets.

#define OPCODE_FROM_FLIC_BUTTON_EVENT_NOTIFICATION 12
struct ButtonEventNotification {
    uint8_t opcode;
    uint32_t event_count;
    struct ButtonEventNotificationItem events[];
} PACKED;

The event_count corresponds to the last element in the events array. The value is designed so that the value divided by four corresponds to the number of times the button has been clicked (almost). The value modulo 4 represents the type of event:

  • 1: down
  • 2: hold
  • 3: up
  • 0: single click timeout

Since the hold and single click timeout events are not always sent, some values for event_count will be skipped. To get the corresponding event_count for another item than the last one, it's possible to count backwards and match the event type description with the modulo 4 descriptions to detect if an event count should be skipped or not. See the Android reference implementation for more details.

After the button events in the events array have been processed and delivered to the user, the persistent storage associated with the button shall be updated with the event_count value. Also if an acknowledgement packet shall be sent (according to the logic above), it is time to do that here. Only one acknowledgement needs to be sent, regardless of how many items were included in the notification.

#define OPCODE_TO_FLIC_ACK_BUTTON_EVENTS_IND 16
struct AckButtonEventsInd {
    uint8_t opcode;
    uint32_t event_count;
} PACKED;

The event_count should be set to the same value as in the ButtonEventNotification packet.

After Init Complete

After a session setup is complete, it is sometimes desired to send different packets as soon as possible, such as a connection parameter change or a battery level request. To get the lowest latency of delivering queued button events after a connection and session setup, it's best to wait with these extra packets until all queued button events have arrived. The has_queued_events field in the init response packet or the was_queued_last in a button event should be used to detect the end of queued events.

Connection Parameter Update

To make the button's battery life last long, it's important to choose good BLE connection parameters. Most Bluetooth stacks by default use a relatively short connection interval (30-50 ms) and no slave latency, which is optimized for transmission speed rather than battery life. Slave latency is ideal for the button use case since it allows the button's radio to stay off unless it has something to send for most connection events, with the ability to get low latency if it has something to send.

For most use cases, it is recommend to use the following connection parameters:

  • Connection interval min: 80 (100 ms)
  • Connection interval max: 90 (112.5 ms)
  • Slave latency: 17
  • Supervision timeout: 800 (8 seconds)

However, keep in mind that a few platforms, such as iOS, have restrictions on what connection parameters that are allowed. Please make sure that the parameters that you choose are allowed on your system.

The connection parameters should be set after all queued events have been received. If the Bluetooth stack exposes a way of setting the connection parameters directly to the desired ones, that is the preferred option. If not, a SetConnectionParametersInd packet should be sent to set the desired parameters:

#define OPCODE_TO_FLIC_SET_CONNECTION_PARAMETERS_IND 12
struct SetConnectionParametersInd {
    uint8_t opcode;
    uint16_t intv_min, intv_max, latency, timeout;
} PACKED;

If multiple sessions set different parameters, the button will choose parameters resulting in the lowest click latency.

Battery Level

The battery level can be requested at any time:

#define OPCODE_TO_FLIC_GET_BATTERY_LEVEL_REQUEST 20
struct GetBatteryLevelRequest {
    uint8_t opcode;
} PACKED;
#define OPCODE_FROM_FLIC_GET_BATTERY_LEVEL_RESPONSE 20
struct GetBatteryLevelResponse {
    uint8_t opcode;
    uint16_t battery_level;
} PACKED;

The Voltage can be calculated as (battery_level * 3.6 / 1024.0). The battery used is a CR2032 battery. Note that it might be hard to estimate how the amount of energy left in such a battery from only the voltage, but generally when it goes below 2.6V you know that it needs be replaced soon.

Terminating a Session

The button may at any time send a DisconnectedVerifiedLinkInd packet when the session is established. Normally this packet should not be observed, unless the protocol specification is violated or DisconnectVerifiedLinkInd has been sent to the button.

#define OPCODE_FROM_FLIC_DISCONNECTED_VERIFIED_LINK_IND 9
struct DisconnectedVerifiedLinkInd {
    uint8_t opcode;
    uint8_t reason;
} PACKED;

When such a packet is received, the session must be terminated and the state must be set to STATE_FAILED.

The reason should be one of the following values but other values might be defined in the future.

enum DisconnectedVerifiedLinkReason {
    DISCONNECTED_VERIFIED_LINK_REASON_PING_TIMEOUT,
    DISCONNECTED_VERIFIED_LINK_REASON_INVALID_SIGNATURE,
    DISCONNECTED_VERIFIED_LINK_REASON_STARTED_NEW_WITH_SAME_PAIRING_IDENTIFIER,
    DISCONNECTED_VERIFIED_LINK_REASON_BY_USER
};

It's also possible to send a DisconnectVerifiedLinkInd to the button:

#define OPCODE_TO_FLIC_DISCONNECT_VERIFIED_LINK_IND 9
struct DisconnectVerifiedLinkInd {
    uint8_t opcode;
} PACKED;

A DisconnectedVerifiedLinkInd packet will be sent from the button confirming the termination with the reason DISCONNECTED_VERIFIED_LINK_REASON_BY_USER and then the session will be terminated at the button's side.

Pinging

The button may at any time send a ping request to detect if the session is still active:

#define OPCODE_FROM_FLIC_PING_REQUEST 15
struct PingRequest {
    uint8_t opcode;
} PACKED;

A response must be sent when a ping request is received:

#define OPCODE_TO_FLIC_PING_RESPONSE 14
struct PingResponse {
    uint8_t opcode;
} PACKED;

Updating Auto Disconnect Time

The auto_disconnect_time set earlier can be changed at any time using the following packet:

#define OPCODE_TO_FLIC_SET_AUTO_DISCONNECT_TIME_IND 19
struct SetAutoDisconnectTimeInd {
    uint8_t opcode;
    uint16_t auto_disconnect_time: 9;
    uint16_t rfu: 7;
} PACKED;

Get Current Time

The time since the button was booted in units of 1/32768 seconds can be requested using the following packet:

#define OPCODE_TO_FLIC_GET_CURRENT_TIME_REQUEST 25
struct GetCurrentTimeRequest {
    uint8_t opcode;
} PACKED;
#define OPCODE_FROM_FLIC_GET_CURRENT_TIME_RESPONSE 23
struct GetCurrentTimeResponse {
    uint8_t opcode;
    uint64_t time: 56;
} PACKED;

Changing Advertising Parameters

This feature requires at least firmware version 7.

All pairings stored on the button have some associated advertising parameters. The settings are stored in RAM and will hence not be persisted upon a button reboot. The parameters specify how the button should advertise when it is in private mode.

#define OPCODE_TO_FLIC_SET_ADV_PARAMETERS_REQUEST 27
struct SetAdvParametersRequest {
    uint8_t opcode;
    bool is_active;
    bool remove_other_pairings_adv_settings;
    bool with_short_range;
    bool with_long_range;
    uint16_t adv_intervals[2]; // 0.625 ms units
    uint32_t timeout_seconds;
} PACKED;

If remove_other_pairings_adv_settings is set, all advertising parameters are first cleared.

If is_active is set to 0, the advertising parameters are removed for this pairing instead of being set.

Otherwise with_short_range and with_long_range configure if the advertisements use LE 1M PHY and/or Coded PHY.

The adv_intervals array contain two advertising intervals, in units of 0.625 ms. The first item is used during the first five seconds after a BLE connection was lost due to connection timeout. The second item is used during the following timeout_seconds seconds.

If advertising should not be performed after a connection loss, both adv_intervals items shall be set to 0 and the timeout_seconds shall also be 0. Otherwise the interval values must fulfill the BLE specification of minimum 32 (20 ms) and maximum 16384 (10.240 s). The timeout_seconds parameter can be set to 0xffffffff for infinity.

#define OPCODE_FROM_FLIC_SET_ADV_PARAMETERS_RESPONSE 27
struct SetAdvParametersResponse {
    uint8_t opcode;
} PACKED;

This response will be received when the parameters have been applied. The idea is that the request should be sent only once for a particular boot_id. When the response has been received, a SetAdvParametersRequest does not need to be sent again until boot_id changes (or the desired parameters changes).

Firmware Update

The button can be firmware updated. The update procedure is performed by first requesting the current firmware version of the button and then requesting the latest firmware from our backend.

It is recommended to perform a firmware update check to our backend at most once every 24 hours. If any internet request fails, please wait at least one hour before trying again. It is also recommended to wait 30 seconds after all queued button events have been received before the the firmware check is performed.

To get the current firmware version of the button, send the following request to the button:

#define OPCODE_TO_FLIC_GET_FIRMWARE_VERSION_REQUEST 8
struct GetFirmwareVersionRequest {
    uint8_t opcode;
} PACKED;

The button will respond with the following packet:

#define OPCODE_FROM_FLIC_GET_FIRMWARE_VERSION_RESPONSE 5
struct GetFirmwareVersionResponse {
    uint8_t opcode;
    uint32_t version;
} PACKED;

To get the latest firmware update, send a POST request to https://api.flic.io/api/v1/buttons/versions/firmware2. Content-Type shall be set to application/json and the body shall consist of a JSON object with the following fields:

  • uuid (string): hex-encoding of the button's UUID (big endian), without hyphens. Example: "ab801970f2194ab8a0debff388e94e06".
  • current_version (integer): current firmware version of the button.
  • platform (string): the platform, such as "Android", "iOS", "Linux", "Windows", "Arduino" etc.
  • lib_version (string): the version number of your library/code base.
  • package_name (string, optional): name of the application.
  • package_version (string, optional): version of the application.

The lib_version, platform (optional), package_name (optional) and package_version (optional) fields are included in case we, for some reason, want to assign a specific firmware version to a button depending on what version of a lib it is using. This is not a feature that is offered at the moment, but it may become relevant later on.

If either the button cannot be found according to the request parameters, or the button has no associated target firmware, the 404 status code will be returned with Content-Type set to "application/json". Also, a JSON object will be returned having a field msg containing either "button_not_found" or "no_flic_firmware_version", respectively.

The server will return status 200 if everything was ok, with a body with Content-Type set to "application/json". The body will either be null if the firmware is already the latest available for the requested parameters. Otherwise a JSON object will be sent with the following field:

  • firmware_download_url (string): a URL where the firmware is found. Send a GET request to this URL to download the firmware file (binary).

The downloaded firmware file contents shall be split into two parts: the first 8 bytes make up iv and the rest data. The data shall be treated as an array of 32-bit words (the byte length of data is guaranteed to be divisible by 4). From now on all lengths and indices are in terms of 32-bit words.

A request is now sent to the button:

#define OPCODE_TO_FLIC_START_FIRMWARE_UPDATE_REQUEST 17
struct StartFirmwareUpdateRequest {
    uint8_t opcode;
    uint16_t len;
    uint64_t iv;
    uint16_t status_interval;
} PACKED;

The iv field shall be set to iv from the firmware file (encoded as a little-endian integer). The len field shall be set to the length of data (remember, number of 32-bit words). The status_interval field must be at least 17, but should be set to 60 for optimal performance. The button will then send a response:

#define OPCODE_FROM_FLIC_START_FIRMWARE_UPDATE_RESPONSE 18
struct StartFirmwareUpdateResponse {
    uint8_t opcode;
    int start_pos;
} PACKED;

If the request failed, start_pos will contain a negative number. The following values for negative start_pos are currently defined:

  • -1: Invalid request parameters.
  • -2: A firmware update is already ongoing, potentially on a different session. Terminating the corresponding session will also cancel the firmware update.
  • -3: A firmware update is already complete. Please disconnect and the button will reboot.

Otherwise start_pos indicates the 32-bit word index the firmware update shall start from. If this index is not 0, a firmware update was interrupted and shall now be continued.

The button has a buffer of 512 words. As the BLE throughput is faster than the button's flash memory, data may only be written if the buffer has space left. When the StartFirmwareUpdateResponse is sent, the buffer is always fully available. Data is written using the following packet:

#define OPCODE_TO_FLIC_FIRMWARE_UPDATE_DATA_IND 18
struct FirmwareUpdateDataInd {
    uint8_t opcode;
    uint32_t words[];
} PACKED;

The length of the words array must not be greater than 30. After appending the signature and header, this will meet the Flic 2 packet length limitation of 128 bytes.

When the number of words processed modulo status_interval is 0, the button will send back a notification:

#define OPCODE_FROM_FLIC_FIRMWARE_UPDATE_NOTIFICATION 19
struct FirmwareUpdateNotification {
    uint8_t opcode;
    int pos;
} PACKED;

The pos fields indicates the number of words that have been processed. This means 512 words can be buffered from this position. This notification will also be sent when all words have been written. In this case pos indicates the length of the firmware which means success. If pos is instead 0, it indicates the firmware did not pass the signature verification test and was rejected.

To achieve good performance, it is recommended to always send as many and as large FirmwareUpdateDataInd packets as possible, while making sure the 512 words buffer limit is not exceeded. After receiving a new FirmwareUpdateNotification, more packets may be sent.

After a firmware update has successfully been performed, the BLE connection to the button shall be disconnected. The button will reboot when the connection has been disconnected. If it's desired to automatically start advertising (in order to automatically reconnect), send a ForceBtDisconnectInd packet (defined below), with the restart field set to true.

Get and Set Name

The button can save a 23-byte long user-defined name (in UTF-8) with an associated UTC timestamp on its flash memory. When the button is new, the name is an empty string.

To just get the current name, the following two packets are used:

#define OPCODE_TO_FLIC_GET_NAME_REQUEST 11
struct GetNameRequest {
    uint8_t opcode;
} PACKED;
#define OPCODE_FROM_FLIC_GET_NAME_RESPONSE 16
struct GetNameResponse {
    uint8_t opcode;
    uint64_t timestamp_utc_ms: 48;
    char name[];
} PACKED;

To update the name:

#define OPCODE_TO_FLIC_SET_NAME_REQUEST 10
struct SetNameRequest {
    uint64_t timestamp_utc_ms: 47;
    uint64_t force_update: 1;
    char name[];
} PACKED;

The timestamp_utc_ms field shall be set to the UNIX timestamp (milliseconds since midnight January 1st, 1970, UTC) that represents the time when the user set the name. The only purpose of the timestamp is to correctly allow the user to set the name even if the button is disconnected (by storing the name temporarily together with the timestamp until the button connects).

The name will only be updated if it differs from the previous name. Also, the timestamp must be newer than the one stored on flash unless the force_update field is set to 1.

If the name is updated, a notification will be sent to all other established sessions (not to the one that sent the request), so they become aware of the change:

#define OPCODE_FROM_FLIC_NAME_UPDATED_NOTIFICATION 14
struct NameUpdatedNotification {
    uint8_t opcode;
    char name[];
} PACKED;

Regardless if the name was updated or not, a response will be sent to the session that sent the request with the current name and timestamp:

#define OPCODE_FROM_FLIC_SET_NAME_RESPONSE 17
struct SetNameResponse {
    uint8_t opcode;
    uint64_t timestamp_utc_ms: 48;
    char name[];
} PACKED;

Timer

For every session, the button has an associated timer that can be programmed to send a notification after a specific amount of time has passed. If a session is terminated, the timer will also be cancelled.

#define OPCODE_TO_FLIC_START_API_TIMER_IND 13
struct StartApiTimerInd {
    uint8_t opcode;
    uint32_t timeout;
    uint32_t message;
} PACKED;

The timeout parameter is in units of 1/32768 seconds. If this packet is sent while a previous timer is already running, the previous timer is cancelled and no notification will be sent for that timer (unless the message has already been queued to be sent before the timer is cancelled). The message is can be set to any value. After the timeout has passed, a notification is sent with the same message:

#define OPCODE_FROM_FLIC_API_TIMER_NOTIFICATION 13
struct ApiTimerNotification {
    uint8_t opcode;
    uint32_t message;
} PACKED;

Connection-less packets

Some packets are connection-less, which means they don't belong to any session. The connId value is always 0 and there is no signature.

#define OPCODE_FROM_FLIC_PAIRING_FINISHED_IND 7
struct PairingFinishedInd {
    uint8_t opcode;
    uint8_t success: 1;
    uint8_t master_sent_fail: 1;
    uint8_t rfu: 6;
    uint8_t reason;
} PACKED;

This packet will be sent when a BLE pairing (through SMP) has finished, and has nothing to do with the otherwise used Flic 2 pairings. The reason parameter is set according to the Bluetooth SMP specification. The success parameter indicates whether the pairing attempt succeeded or not. The master_sent_fail bit will be set if the master sent a "Pairing Failed" SMP packet.

#define OPCODE_TO_FLIC_BLE_SECURITY_REQUEST_IND 7
struct BleSecurityRequestInd {
    uint8_t opcode;
} PACKED;

The packet above can be sent in order to make the button send a SMP Security Request command, as long as no SMP pairing attempt is currently ongoing. This will initiate a BLE pairing.

#define OPCODE_TO_FLIC_FORCE_BT_DISCONNECT_IND 6
struct ForceBtDisconnectInd {
    uint8_t opcode;
    bool restart_adv;
} PACKED;

This packet can be sent at any time. It forces the button to disconnect the BLE connection. If restart_adv is set, the button will automatically start advertise after disconnection, or after reboot if a firmware update has been performed.