Leverage device discovery on iOS

The Discovery API for iOS is intended to be used by apps that can create automations based on the devices present in the user's home. It can reveal to an app at runtime what traits and devices are present in a given structure for use in automations. In addition, it exposes the associated commands, attributes, and events, as well as the range of values that are allowed for parameters and fields.

The Discovery API ignores any devices or traits that exist in a structure that aren't supported by the Automation API, as well as any devices or traits that weren't registered during Home configuration. See Home configuration for more information.

Use the API

At the core of the Discovery API for iOS are two structure-level method:

  • candidates(includeDescendants:) — Returns a list of automation candidate nodes for a structure. Set includeDescendates to true to get child candidates for all members of the structure.
  • candidates(for:) — Returns a list of automation candidate nodes for a device in a structure.

Since the initial release of the Discovery API is non-streaming, these methods return a one-time snapshot.

To obtain the most up-to-date list of available candidates, the developer must call candidates(includeDescendants:) or candidates(for:) each time. Furthermore, because these two methods are especially resource-intensive, calling them more often than once per minute will result in cached data being returned, which may not reflect the actual current state at that moment.

The NodeCandidate protocol represents a candidate node and defines the common attributes for all Discovery candidates.

Each of the following protocols are used to convert a candidate to the appropriate node for use in an automation graph:

The following candidates are structs that correspond to the elements that can be used in starters, actions, and state readers:

Work with automation candidates

Say you're writing an app that creates an automation to close a set of smart blinds at a user-specified time. However, you don't know whether the user has a device that supports the WindowCoveringTrait trait and whether WindowCoveringDeviceType or any of its attributes or commands can be used in automations.

The following code illustrates how to use the Discovery API to filter the output of the candidates() method to narrow down the results and obtain the specific kind of element (structure, event, command) being sought. At the end, it creates an automation out of the collected elements.

import GoogleHomeSDK
import GoogleHomeTypes

private func createAutomationWithDiscoveryAPITimeStarter(
  structureName: String,
  scheduledTime: TimeOfDay
) async throws -> String {
  guard
    let structure = try await self.home.structures().list().first(where: {
      $0.name == structureName
    })
  else {
    return ""
  }

  guard
    try await structure.candidates().list()
      .compactMap({ $0.node as? EventCandidate })
      .contains(where: {
        $0.event is GoogleHomeTypes.Google.TimeTrait.ScheduledEvent.Type
          && $0.unsupportedReasons.isEmpty
      })
  else {
    return ""
  }

  guard
    try await structure.candidates().list()
      .compactMap({ $0.node as? CommandCandidate })
      .contains(where: {
        $0.command is Matter.WindowCoveringTrait.DownOrCloseCommand.Type
          && $0.unsupportedReasons.isEmpty
      })
  else {
    return ""
  }

  var blindsDevice: HomeDevice? = nil

  // prompt user to select the WindowCoveringDevice
  ...

  guard let blindsDevice else {
    return ""
  }

  // Create the draft automation.
  let draftAutomation = automation(
    name: "",
    description: ""
  ) {
    starter(structure, Google.TimeTrait.ScheduledEvent.self) {
      GoogleHomeTypes.Google.TimeTrait.ScheduledEvent.clockTime(scheduledTime)
    }
    action(blindsDevice, WindowCoveringDeviceType.self) {
      Matter.WindowCoveringTrait.downOrClose()
    }
  }

  // Create the automation in the structure.
  let automation = try await structure.createAutomation(draftAutomation)
  return automation.id
}

The following example creates an automation to set the brightness level of a light when it is turned on.

import GoogleHomeSDK
import GoogleHomeTypes

private func createAutomationWithDiscoveryAPIForDimmableLight(structureName: String) async throws -> String {
  // Get the structure.
  let structures = try await self.home.structures().list()
  guard let structure = structures.first(where: { $0.name == structureName }) else {
    return ""
  }

  // Get the devices in the structure.
  let structureDevices = try await self.home.devices().list().filter {
    $0.structureID == structure.id
  }

  // Get the candidates.
  let allCandidates = try await structure.candidates().list()

  // Get the first dimmable light device.
  guard
    let dimmableLightDevice = structureDevices.first(where: {
      $0.types.contains(DimmableLightDeviceType.self)
    })
  else {
    return ""
  }

  // Validate that the `Matter.OnOffTrait` attribute starter candidate is present for the dimmable
  // light device.
  guard
    allCandidates
      .compactMap({ $0.node as? TraitAttributesCandidate })
      .contains(where: {
        $0.homeObject.id == dimmableLightDevice.id && $0.trait is Matter.OnOffTrait.Type
      })
  else {
    return ""
  }

  // Validate that the `LevelControlTrait.MoveToLevelCommand`` candidate is present for the
  // dimmable light device.
  guard
    allCandidates
      .compactMap({ $0.node as? CommandCandidate })
      .contains(where: {
        $0.homeObject.id == dimmableLightDevice.id
          && $0.command is Matter.LevelControlTrait.MoveToLevelCommand.Type
      })
  else {
    return ""
  }

  // Create the draft automation.
  let draftAutomation = automation {
    let onOffStarter = starter(
      dimmableLightDevice, OnOffLightDeviceType.self, Matter.OnOffTrait.self)
    condition { onOffStarter.onOff.equals(true) }
    action(dimmableLightDevice, DimmableLightDeviceType.self) {
      Matter.LevelControlTrait.moveToLevel(
        level: 55,
        transitionTime: nil,
        optionsMask: Matter.LevelControlTrait.OptionsBitmap(),
        optionsOverride: Matter.LevelControlTrait.OptionsBitmap()
      )
    }
  }

  // Create the automation in the structure.
  let automation = try await structure.createAutomation(draftAutomation)
  return automation.id
}

Verify that a trait attribute supports your use case

While all trait attributes can be used as a state reader, not all trait attributes can be used as a starter, and not all trait attributes can be modified. You should check each attribute that you intend to use to see whether it supports what you want to do with it.

The TraitAttributesCandidate is the starting point for checking attributes. This class represents a trait. Each trait has one or more attributes associated with it. The trait's attributes are found in the TraitAttributesCandidate.fieldDetails, which is a map of AttributeFieldDetails instances, each one representing one of the trait's attributes.

So, to determine what an attribute supports:

Check for prerequisites

The Discovery API lets you know a trait is missing a prerequisite for use, such as a subscription or a structure address. It does this using the NodeCandidate's unsupportedReasons attribute.

This attribute is provided by the NodeCandidate protocol, which all candidates protocols and structures (CommandCandidate), (EventCandidate, and TraitAttributesCandidate) conform to and is populated with an UnsupportedCandidateReason list of UnsupportedCandidateReasonTypes during either candidates() call. The same information also appears in the validation error messages when createAutomation(_:) is called.

Example reasons:

For example, to handle the MissingStructureAddressSetup UnsupportedCandidateReason, you might want to show an alert in your app and open the GHA to allow the user to provide the address of the structure:

guard let structure = try await home.structures().list().first else { return }

  let allCandidates = try await structure.candidates().list()

  guard
    let scheduledStarterCandidate =
      allCandidates
      .compactMap({ $0.node as? EventCandidate })
      .first(where: {
        $0.event is GoogleHomeTypes.Google.TimeTrait.ScheduledEvent.Type
      })
  else {
    return
  }

  if scheduledStarterCandidate.unsupportedReasons.contains(where: {
    $0.reasonType == .missingStructureAddressSetup
  }) {
    showToast("No Structure Address setup. Redirecting to GHA to setup address.")
    launchChangeAddress()
  }

Validate parameters

The Discovery API returns the values that are allowed for an attribute, parameter, or event field, in the form of a Constraint instance. This information allows the app developer to prevent users from setting invalid values.

Each of the subclasses of Constraint have their own way to represent accepted values.

Table: Types of Constraint
Constraint class Properties representing accepted values
BitmapConstraint combinedBits: [CombinedBitsDescriptor], typeId: String
BooleanConstraint booleanConstraint
ByteConstraint minLength: UInt32, maxLength: UInt32
EnumConstraint allowedSet: Set
StringConstraint minLength: UInt32 = 0, maxLength: UInt32 = 256, disallowedValues: [String], disallowedValuesCaseSensitive: Bool = false, regex: String = ""
StructConstraint structFieldConstraint: [AnyField : Constraint]
ListConstraint elementConstraint: Constraint

Use constraints

Say you're writing an app that creates an automation that sets the level of a device with the LevelControlTrait. The following example shows how you would ensure that the value used to set the LevelControlTrait's currentLevel attribute is within the accepted bounds.

import GoogleHomeSDK
import GoogleHomeTypes

  ...

  guard
    let levelCommandCandidate = try await structure.candidates().list()
      .compactMap({ $0.node as? CommandCandidate })
      .first(where: {
        $0.command is Matter.LevelControlTrait.MoveToLevelCommand.Type
      })
  else {
    return
  }

  let field = AnyField(
    erasing: Matter.LevelControlTrait.MoveToLevelCommand.CommandRequestFields.level)
  guard let levelConstraint = levelCommandCandidate.fieldDetails[field]?.constraint else {
    return
  }

  guard case .uintRangeConstraint(let lowerBound, let upperBound, _, _) = levelConstraint else {
    return
  }

  if (lowerBound...upperBound).contains(value) {
    // OK to use the value
  }

Compare the Device API and Discovery API

You can discover device types, traits, and their attributes without using the Discovery API. Using the Device API, you can discover:

  1. The primary device types the user has granted permission to the developer for control, using the DeviceType.Metadata.isPrimaryType() attribute.
  2. Whether each device supports all the traits the automation requires, using the DeviceType.traits.contains(_:) method.
  3. Whether each trait supports all attributes, events, and commands the automation requires, using the supportedEventTypes and supportedCommandTypes methods specific to each trait.

It's important to note that if you use the Device API to do discovery, you don't benefit from the following Discovery API capabilities:

  • Automatic filtering out of traits that aren't supported by the Automation API.
  • The ability to provide users with an option to select a valid value for those attributes and parameters that use constraints.