BFT Gate Control Integration

I’ve created a custom cover component to control my driveway gates but am having issues trying to set the state of the frontend buttons.

When the gates are closed, there is the option to open and stop as expected. When I click open I can stop the gates but once stopped, I can only continue to open the gates. What I would like is the ability to hit close.

There’s no reason for the gates to have to fully open before closing again.

03

Is there any way to override the open/close buttons to allow them to be clicked when the cover is in the “stopped” state and just after open/close has been clicked? The gates will accept “close” when they are opening or stopped and will immediately close the gates.

The component code is below. Note I only have a “moving” state and not opening/closing as it’s difficult to determine this from the manufacturer’s API.

"""Platform for the BFT cover component."""
import logging

import requests
import voluptuous as vol

import homeassistant.helpers.config_validation as cv
from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA
from homeassistant.helpers.event import track_utc_time_change
from homeassistant.const import (
    CONF_DEVICE,
    CONF_USERNAME,
    CONF_PASSWORD,
    CONF_ACCESS_TOKEN,
    CONF_NAME,
    STATE_CLOSED,
    STATE_OPEN,
    CONF_COVERS,
)

_LOGGER = logging.getLogger(__name__)

ATTR_AVAILABLE = "available"
ATTR_TIME_IN_STATE = "time_in_state"

DEFAULT_NAME = "BFT"

STATE_MOVING = "moving"
STATE_OFFLINE = "offline"
STATE_STOPPED = "stopped"

STATES_MAP = {
    "open": STATE_OPEN,
    "moving": STATE_MOVING,
    "closed": STATE_CLOSED,
    "stopped": STATE_STOPPED,
}

COVER_SCHEMA = vol.Schema(
    {
        vol.Optional(CONF_ACCESS_TOKEN): cv.string,
        vol.Optional(CONF_DEVICE): cv.string,
        vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
        vol.Optional(CONF_PASSWORD): cv.string,
        vol.Optional(CONF_USERNAME): cv.string,
    }
)

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
    {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA)}
)


def setup_platform(hass, config, add_entities, discovery_info=None):
    """Set up the BFT covers."""
    covers = []
    devices = config.get(CONF_COVERS)

    for device_name, device_config in devices.items():
        args = {
            "name": device_config.get(CONF_NAME),
            "device": device_config.get(CONF_DEVICE),
            "username": device_config.get(CONF_USERNAME),
            "password": device_config.get(CONF_PASSWORD),
            "access_token": device_config.get(CONF_ACCESS_TOKEN),
        }

        covers.append(BftCover(hass, args))

    add_entities(covers)


class BftCover(CoverDevice):
    """Representation of a BFT cover."""

    def __init__(self, hass, args):
        """Initialize the cover."""
        self.particle_url = "https://ucontrol-api.bft-automation.com"
        self.dispatcher_api_url = (
            "https://ucontrol-dispatcher.bft-automation.com/automations"
        )
        self.hass = hass
        self._name = args["name"]
        self.device_name = args["device"]
        self.device_id = None
        self.access_token = args["access_token"]
        self.obtained_token = False
        self._username = args["username"]
        self._password = args["password"]
        self._state = None
        self.time_in_state = None
        self._unsub_listener_cover = None
        self._available = True

        if self.access_token is None:
            self.access_token = self.get_token()
            self._obtained_token = True

        if self.device_id is None:
            self.device_id = self.get_device_id()

        try:
            self.update()
        except requests.exceptions.ConnectionError as ex:
            _LOGGER.error("Unable to connect to server: %(reason)s", dict(reason=ex))
            self._state = STATE_OFFLINE
            self._available = False
            self._name = DEFAULT_NAME
        except KeyError:
            _LOGGER.warning(
                "BFT device %(device)s seems to be offline", dict(device=self.device_id)
            )
            self._name = DEFAULT_NAME
            self._state = STATE_OFFLINE
            self._available = False

    def __del__(self):
        """Try to remove token."""
        if self._obtained_token is True:
            if self.access_token is not None:
                self.remove_token()

    @property
    def name(self):
        """Return the name of the cover."""
        return self._name

    @property
    def should_poll(self):
        """No polling needed for a demo cover."""
        return True

    @property
    def available(self):
        """Return True if entity is available."""
        return self._available

    @property
    def device_state_attributes(self):
        """Return the device state attributes."""
        data = {}

        if self.time_in_state is not None:
            data[ATTR_TIME_IN_STATE] = self.time_in_state

        if self.access_token is not None:
            data[CONF_ACCESS_TOKEN] = self.access_token

        return data

    @property
    def is_closed(self):
        """Return if the cover is closed."""
        if self._state is None:
            return None
        return self._state == STATE_CLOSED

    @property
    def device_class(self):
        """Return the class of this device, from component DEVICE_CLASSES."""
        return "door"

    def get_token(self):
        """Get new token for usage during this session."""
        args = {
            "grant_type": "password",
            "username": self._username,
            "password": self._password,
        }
        url = "{}/oauth/token".format(self.particle_url)
        ret = requests.post(url, auth=("particle", "particle"), data=args, timeout=10)

        try:
            return ret.json()["access_token"]
        except KeyError:
            _LOGGER.error("Unable to retrieve access token %s")

    def get_device_id(self):
        """Get device id from name."""
        url = "{}/api/v1/users/?access_token={}".format(
            self.particle_url, self.access_token
        )
        ret = requests.get(url, timeout=10)
        for automations in ret.json()["data"]["automations"]:
            if automations["info"]["name"] == self.device_name:
                _LOGGER.debug("UUID: %s" % automations["uuid"])
                _LOGGER.debug("Device Name: %s" % automations["info"]["name"])
                return automations["uuid"]

    def remove_token(self):
        """Remove authorization token from API."""
        url = "{}/v1/access_tokens/{}".format(self.particle_url, self.access_token)
        ret = requests.delete(url, auth=(self._username, self._password), timeout=10)
        return ret.text

    def _start_watcher(self, command):
        """Start watcher."""
        _LOGGER.debug("Starting Watcher for command: %s ", command)
        if self._unsub_listener_cover is None:
            self._unsub_listener_cover = track_utc_time_change(
                self.hass, self._check_state
            )

    def _check_state(self, now):
        """Check the state of the service during an operation."""
        self.schedule_update_ha_state(True)

    def close_cover(self, **kwargs):
        """Close the cover."""
        if self._state not in ["close"]:
            ret = self._put_command("close")
            self._start_watcher("close")
            return ret.get("status") == "done"

    def open_cover(self, **kwargs):
        """Open the cover."""
        if self._state not in ["open"]:
            ret = self._put_command("open")
            self._start_watcher("open")
            return ret.get("status") == "done"

    def stop_cover(self, **kwargs):
        """Stop the door where it is."""
        if self._state not in ["stopped"]:
            ret = self._put_command("stop")
            self._start_watcher("stop")
            return ret["status"] == "done"

    def update(self):
        """Get updated status from API."""
        try:
            status = self._get_variable("diagnosis")
            self._state = self._get_gate_status(status)
            _LOGGER.debug(self._state)
            self._available = True
        except requests.exceptions.ConnectionError as ex:
            _LOGGER.error("Unable to connect to server: %(reason)s", dict(reason=ex))
            self._state = STATE_OFFLINE
        except KeyError:
            _LOGGER.warning(
                "BFT device %(device)s seems to be offline", dict(device=self.device_id)
            )
            self._state = STATE_OFFLINE

        if self._state not in [STATE_MOVING]:
            if self._unsub_listener_cover is not None:
                self._unsub_listener_cover()
                self._unsub_listener_cover = None

    def _get_gate_status(self, status):
        """Get gate status from position and velocity."""
        _LOGGER.debug("Current Status: %s", status)
        first_engine_pos_int = status["first_engine_pos_int"]
        second_engine_pos_int = status["second_engine_pos_int"]
        first_engine_vel_int = status["first_engine_vel_int"]
        second_engine_vel_int = status["second_engine_vel_int"]

        if (
            (first_engine_pos_int == 100 and second_engine_pos_int == 100)
            and (first_engine_vel_int == 0 and second_engine_vel_int == 0)
        ):
            _LOGGER.warning("open")
            return STATES_MAP.get("open", None)
        if (
            (first_engine_vel_int == 0 and second_engine_vel_int == 0)
            and (first_engine_pos_int > 0 or second_engine_pos_int > 0)
        ):
            _LOGGER.warning("stopped")
            return STATES_MAP.get("stopped", None)
        if (
            (first_engine_pos_int == 0 and second_engine_pos_int == 0)
            and (first_engine_vel_int == 0 and second_engine_vel_int == 0)
        ):
            _LOGGER.warning("closed")
            return STATES_MAP.get("closed", None)
        if first_engine_vel_int > 0 or second_engine_vel_int > 0:
            _LOGGER.warning("moving")
            return STATES_MAP.get("moving", None)

    def _get_variable(self, var):
        """Get latest status."""
        api_call_headers = {"Authorization": "Bearer " + self.access_token}
        url = "{}/{}/execute/{}".format(self.dispatcher_api_url, self.device_id, var)
        _LOGGER.debug(url)
        ret = requests.get(url, timeout=10, headers=api_call_headers)
        return ret.json()

    def _put_command(self, func):
        """Send commands to API."""
        api_call_headers = {"Authorization": "Bearer " + self.access_token}
        url = "{}/{}/execute/{}".format(self.dispatcher_api_url, self.device_id, func)
        _LOGGER.debug(url)
        ret = requests.get(url, timeout=10, headers=api_call_headers)
        return ret.json()

I was wondering the same thing… Did you find a way to solve this?

Still wondering about the same thing. Does anybody have some info about the BFT api? I can’t find anything about it…

@perimore seems to be the only guy around with some info on the BFT api but he’s not responding to messages. So here’s what I found out. Might be helpful for those with a BFT gate equipped with a WiFi module.

You can make @perimore code work by creating a folder named bft in custom_components. Then create two files inside, __init__.py (leave empty) and cover.py, where you copy the code exactly as it is.

Then add

cover:
  - platform: bft
    covers:
      bft:
        username: [email protected]
        password: yourpassword
        device: "your device name EXACTLY as you named it in the BFT app"

And that’s it. A couple of caveats. Log keeps showing this warning every few seconds, I guess it has to do with the code polling the api:

closed
10:24:00 AM – custom_components/bft/cover.py (WARNING) - message first occurred at 9:59:03 AM and shows up 111 times
Logger: custom_components.bft.cover
Source: custom_components/bft/cover.py:233 
First occurred: 9:59:03 AM (111 occurrences) 
Last logged: 10:24:00 AM
closed
stopped
moving

So what the code does is basically this. It gets an authorization token by calling https://ucontrol-api.bft-automation.com, though I can’t figure out what’s the exact request when these lines of code are executed:

def get_token(self):
        """Get new token for usage during this session."""
        args = {
            "grant_type": "password",
            "username": self._username,
            "password": self._password,
        }
        url = "{}/oauth/token".format(self.particle_url)
        ret = requests.post(url, auth=("particle", "particle"), data=args, timeout=10)

        try:
            return ret.json()["access_token"]
        except KeyError:
            _LOGGER.error("Unable to retrieve access token %s")

Once it gets the token, you can find your device uuid by hitting https://ucontrol-api.bft-automation.com/api/v1/users/?access_token=yourtokenhere with your username and password as headers. And then once you have your uuid, you can get the device status and send commands by hitting https://ucontrol-dispatcher.bft-automation.com/automations/youruuidhere/execute/diagnosis with username, password, and Authorization (bearer + token) as headers.

Works great with HomeKit and Alexa through Home Assistant.

Hope this helps somebody. Or at least I hope somebody can dig into it a little more.

Sorry for the lack of response, I didn’t notice the chatter. BFT do not publish any details of the API but I reversed engineered using a https proxy and so far it hasn’t changed.

I didn’t work out how to close whilst in the stopped state but tbh it’s working as well as it needs to be.

I seemed to have a more up to date version locally that I’m also using in my HA setup. Check this out in my branched repo:

Let me know how you get on with it.

Cheers,
Sean

To confirm, the version in my repo is the same as the working version in my HA setup.

I updated the code so you don’t need to find the device ID. The code extracts this from the device name found in your BFT u-control app. So the only config you need in configuration.yaml is:

cover:
  - platform: bft
    covers:
      driveway:
        device: Driveway Gate
        username: <user-id>
        password: <password>
        name: driveway

In my setup “Driveway Gate” is the name shown in the u-control app.

HTH.

Sean

Thanks a lot for this! A couple of questions, what is the url (+ headers) you need to hit to get the access token? I’m trying to develop a Homebridge plug-in. Also, have you tried accessing the device info through ssh instead of web api?

Again, great job, thanks

I think they are using one of these boards and their provided API:

https://docs.particle.io/

A few pointers here:

https://docs.particle.io/reference/device-cloud/api/#generate-an-access-token

You should be able to see the URL in my code:

    def get_token(self):
        """Get new token for usage during this session."""
        args = {
            "grant_type": "password",
            "username": self._username,
            "password": self._password,
        }
        url = "{}/oauth/token".format(self.particle_url)
        ret = requests.post(url, auth=("particle", "particle"), data=args, timeout=10)

Therefore:

url = https://ucontrol-api.bft-automation.com/oauth/token

requests.post(url, auth=(“particle”, “particle”), data=args, timeout=10)

Where:

args = {
“grant_type”: “password”,
“username”: self._username,
“password”: self._password,z

An example I just ran using the particle docs:

➜  bft git:(dev) curl https://ucontrol-api.bft-automation.com/oauth/token \
       -u particle:particle \
       -d grant_type=password \
       -d "username=<user>" \
       -d "password=<pass>"
{"access_token":"<received_token","token_type":"bearer","expires_in":115430400,"refresh_token":"<received_refresh_token>","created_at":1586191227}% ➜  bft git:(dev)

All working great. I’ve also been trying to get “opening” and “closing” states with reed sensors at the gate, but you can only guess based on close.cover and open.cover service calls (HomeKit expects opening, closing and stopped states, not only open and close). With your integration, “stopped” and “moving” states can very very useful to achieve that, and I guess opening and closing could be even be inserted in the cover.py code. Thanks!

Did you ever figure out how to get opening and closing state? The BFT app shows the progress of the gate and it can be polled via the API but it scales from <0 and >100 which is pretty confusing. I guess this might be gate angle in degrees.

I just couldn’t find “opening” or “closing”.

Thanks.

Turns out the cover component does not allow states other than open and close. The way HomeKit is implemented in Home Assistant does not comply with HMCharacteristic. I guess it would have to be written from scratch for API polling to get the gate’s position.

I installed 2 sensors and built a sensor template (when sensor 1 was closed and it just opened, while sensor 2 is open, door is opening; when sensor 2 was closed and it just opened, while sensor 1 is open, door is closing) that showed the correct state of the gate, but then again, Home Assistant would not pass those values to HomeKit, only open and close values. Truth be told, I’m having lots of problems with the cover component in HomeKit (sluggish, false openings, etc.).

Maybe HomeBridge is a better idea at this point? Any thoughts? Thanks!

Sorry to reopen an old thread but thought I would provide an update a year on. My integration is still working but BFT’s cloud service is very flakey! I’m still using it but I have just successfully integrated a Remootio controller into my garage opener and use a node-red “flow” (if that’s the right term) to control it directly via an API. This means the response is instant and there’s no reliance on any cloud server although it is still there as a backup.

Next step I’m going to put one of the remootio controllers into my BFT opener as they provide various wiring guides to do this:

Details of the Node-Red integration:

This requires you to install Node-Red, I do this via the Hassio supervisor and also “Node-Red companion” via HACs. From here you can create a flow similar to the below:

Of course, change the name to “gate” etc.

You’ll have to do some reading but it’s fairly straightforward to work out.

When I get the 2nd controller into the BFT box I’ll send an update. Remootio takes 5V DC and the BFT seems to have a 24V output so I think I’ll need a DC to DC convertor.

Hope this helps.

You’ll also need a cover template in HA:

  - platform: template
    covers:
      remootio_garage_door:
        device_class: garage
        friendly_name: "Remootio Garage Door"
        #value_template: "{{ states('sensor.remootio_garage_state') }}"
        value_template: >
          {% set s = states('sensor.remootio_garage_state') %}
          {{ s if s in ['open', 'closed'] else 'open' }}
        open_cover:
          - condition: state
            entity_id: sensor.remootio_garage_state
            state: "closed"
          - service: switch.turn_on
            target:
              entity_id: switch.remootio_garage_switch
        close_cover:
           - condition: state
             entity_id: sensor.remootio_garage_state
             state: "open"
           - service: switch.turn_off
             target:
               entity_id: switch.remootio_garage_switch
        stop_cover:
          service: switch.turn_on
          target:
            entity_id: switch.remootio_garage_switch

@perimore Hi there, I know this is an old topic. I wanted to do the integration for my bft gate with the repo you created but it doesn’t exist anymore on github. Would you be willing to upload it again? Many thanks

You should find the code here. Just stick those files in the custom components folder with the config and it should work

Hi @perimore thank you for sharing your custom bft component. I’ve been using it for the past couple of years and other than the flakey BFT cloud service which regularly times out, and the wifi module having a weak antenna it has been great.

I too have become frustrated with having to wait for the cloud service to respond (sometimes 30 seconds plus) and the cover status flapping in home assistant/apple homekit (due to the cloud service timing out). So I’ve finally installed a ZWave relay to trigger the gate motion locally removing the dependency on the BFT wifi module, internet or the BFT cloud platform.

I have essentially started to do what is described here with a Qubino Flush 1D Relay.

I’m currently powering the relay with 240V from the supply to my BFT Thalia, I tried 24V DC from the accessory terminals 50/51 but the relay would not power up. Not sure if this is insufficient power or something else. Because of this I don’t think I can connect the switch on the relay to pickup the gate status, so I’m going to try powering it directly from the transformer when I get some more spade connectors.

Did you resolve your DC power challenge? The transformer in my Thalia has 3 different output terminals which are all at different voltages I believe.

So now I’m able to start/stop the motor with the relay which is set to automatically switch off after 500ms. I had to change the BFT configuration logic for terminal ic1 from setting 2 (Open) to 1 (Start I).

No more frustrating delays or blackouts (usually caused by Internet/WiFi faults) now I have local control with the much more robust ZWave.

I’m still using your BFT custom module for the gate status until I can get the relay switch hooked up.

Hello, I am a new user et it is difficult for me to understand.
I want to control my bft portal in home assistant. I have the official wifi module and I basically use the app.

I tried to follow the tutorial but I don’t quite understand how it works.
For now I have created a custom folder and created the 2 files as indicated by @uspino and @perimore .
But I am now stuck.

Can you tell me the code to indicate in the file and how to integrate it in home assistant ?

Thank you so much because it is very important for me !

Benjamin (a french user)

Hi @Benjamin_45, I presume you have copied the bft directory into your custom_components directory within your home assistant installation? The link to @perimore’s home-assistant repository no longer work but it’s still available here .

Once you’ve got the bft custom component in place, you just need to add to your configuration.yaml and restart home assistant and your gate should appear as a cover entity. My configuration.yaml is below as an example (bft_user and bft_password are tokens for the values stored in my secrets.yaml).

cover:
  - platform: bft
    covers:
      driveway:
        device: "Main-Gate"
        username: !secret bft_user
        password: !secret bft_password
        name: "Main Gate"

Hey, not sure what repository the old link was meant to go to but the forked repo should be enough.

I thought about this the other day in fact and how to fix the flapping state. Maybe the integration should somehow assume the last known state if the cloud service is unreachable. Not sure if that will fix it but will give it a shot soon.

I abandoned the Remootio integration as I realised that unit can only send a single output and would require an external sensor to establish if the door was open or closed.