Brussels Public Transportation (STIB-MIVB) integration (API v2)

Hi,
I made a new version of the stib script:

It can use both method with online api data or mixed with GTFS data which provides enhanced data.
As the data is inconsistent, we can’t get good trip details from the API and the sensor name differs for both methods.
To run this script, create the config.yaml file and use python3 sensor.py.
Let me know if this works for you.
If you uses the former script, you will probably end with some unused sensors, to get rid of them, send an empty mqtt message to the config topic.

Dan

hello, thank you for the new script, it working very well
i made some modification on to make it work better for me
i m in litlle problem with stop names, when i look for a string as stop name, it will get all the stop name that contain the string
i tried to resolve it by using “=” instead of “like” on the quiery,

where_stop_names = " OR ".join(' stop_name = "' + item + '"' for item in STOP_NAMES)

but no succes !!

any idea?

Hi,
this line constructs the query which is send to the stib api.

stop_name like "FOREST"  [or stop_name like 'SAINT-DENIS' or ...]

It adds an OR stop_name like… for each entry.

For a stop name, you get different stop ids, at least 2, one per way.
With the ‘like’, If you put just a part of the stop name, you might get a lot of stop ids. For example if I put “FOREST” I get Forest Centre, Fores Bervoets, Forest National, Chaussée de Forest… But if I put “FOREST-CENTRE” I get only the ids for this particular stop.
If we use a ‘=’, it will search for a stop_name “FOREST” which doesn’t exist, no results. So we need to be sure to put the right stop names in the list in the configuration file.

What did you put as stop name(s)?

Hello,

I used “DOMAINE” as stop name
but i got “DOMAINE” as result, and also “DGHR DOMAINE MIL.” i dont even know where is it lol… so i got 4 sensors that i dont need

The solution was to tell the script to quesry only stop name “DOMAINE” and the stop name containing “DOMAINE”

i tried to query with code station, but not very strong at python

finaly, i use this, and it seem to work

    where_stop_names = ' OR '.join(f'LOWER(stop_name) = LOWER("{item}") AND LENGTH(stop_name) = {len(item)}' for item in STOP_NAMES)

thank you for all you support

i add two function that keep the script running if the network is not reacheable

here is my fork

Hi again @nxd4n
I try your last version of stibgtfs2mqtt but I get this error:

root@odroidhc4:~/stibgtfs2mqtt-main/mqttsensor# python3 sensor.py 
{'71': {'pointid': '3520', 'lineid': '71', 'passingtimes': [{'destination': {'fr': 'DE BROUCKERE', 'nl': 'DE BROUCKERE'}, 'expectedArrivalTime': '2024-05-03T17:52:00+02:00', 'lineId': '71'}, {'destination': {'fr': 'DE BROUCKERE', 'nl': 'DE BROUCKERE'}, 'expectedArrivalTime': '2024-05-03T17:57:00+02:00', 'lineId': '71'}]}}
{'5': {'pointid': '8232', 'lineid': '5', 'passingtimes': [{'destination': {'fr': 'HERRMANN-DEBROUX', 'nl': 'HERRMANN-DEBROUX'}, 'expectedArrivalTime': '2024-05-03T17:54:00+02:00', 'lineId': '5'}, {'destination': {'fr': 'HERRMANN-DEBROUX', 'nl': 'HERRMANN-DEBROUX'}, 'expectedArrivalTime': '2024-05-03T17:58:00+02:00', 'lineId': '5'}]}}
{'72': {'pointid': '3546', 'lineid': '72', 'passingtimes': [{'destination': {'fr': 'ADEPS', 'nl': 'ADEPS'}, 'expectedArrivalTime': '2024-05-03T18:41:00+02:00', 'lineId': '72'}, {'expectedArrivalTime': '2024-05-03T17:52:00+02:00', 'lineId': '72'}]}}
{'72': {'pointid': '3556', 'lineid': '72', 'passingtimes': [{'destination': {'fr': 'ULB', 'nl': 'ULB'}, 'expectedArrivalTime': '2024-05-03T18:16:00+02:00', 'lineId': '72'}, {'expectedArrivalTime': '2024-05-03T17:52:00+02:00', 'lineId': '72'}]}, '71': {'pointid': '3556', 'lineid': '71', 'passingtimes': [{'destination': {'fr': 'DE BROUCKERE', 'nl': 'DE BROUCKERE'}, 'expectedArrivalTime': '2024-05-03T17:55:00+02:00', 'lineId': '71'}, {'destination': {'fr': 'DE BROUCKERE', 'nl': 'DE BROUCKERE'}, 'expectedArrivalTime': '2024-05-03T18:00:00+02:00', 'lineId': '71'}]}}
{'5': {'pointid': '8231', 'lineid': '5', 'passingtimes': [{'destination': {'fr': 'ERASME', 'nl': 'ERASMUS'}, 'expectedArrivalTime': '2024-05-03T17:52:00+02:00', 'lineId': '5'}, {'destination': {'fr': 'ERASME', 'nl': 'ERASMUS'}, 'expectedArrivalTime': '2024-05-03T17:58:00+02:00', 'lineId': '5'}]}}
Retrieving STIB-MIVB realtime data
{"line_ids": ["72", "5", "71"], "waiting_times": {"3556": {"72": {"pointid": "3556", "lineid": "72", "passingtimes": [{"destination": {"fr": "ULB", "nl": "ULB"}, "expectedArrivalTime": "2024-05-03T18:16:00+02:00", "lineId": "72"}, {"expectedArrivalTime": "2024-05-03T17:52:00+02:00", "lineId": "72"}]}, "71": {"pointid": "3556", "lineid": "71", "passingtimes": [{"destination": {"fr": "DE BROUCKERE", "nl": "DE BROUCKERE"}, "expectedArrivalTime": "2024-05-03T17:55:00+02:00", "lineId": "71"}, {"destination": {"fr": "DE BROUCKERE", "nl": "DE BROUCKERE"}, "expectedArrivalTime": "2024-05-03T18:00:00+02:00", "lineId": "71"}]}}, "3546": {"72": {"pointid": "3546", "lineid": "72", "passingtimes": [{"destination": {"fr": "ADEPS", "nl": "ADEPS"}, "expectedArrivalTime": "2024-05-03T18:41:00+02:00", "lineId": "72"}, {"expectedArrivalTime": "2024-05-03T17:52:00+02:00", "lineId": "72"}]}}, "8231": {"5": {"pointid": "8231", "lineid": "5", "passingtimes": [{"destination": {"fr": "ERASME", "nl": "ERASMUS"}, "expectedArrivalTime": "2024-05-03T17:52:00+02:00", "lineId": "5"}, {"destination": {"fr": "ERASME", "nl": "ERASMUS"}, "expectedArrivalTime": "2024-05-03T17:58:00+02:00", "lineId": "5"}]}}, "3520": {"71": {"pointid": "3520", "lineid": "71", "passingtimes": [{"destination": {"fr": "DE BROUCKERE", "nl": "DE BROUCKERE"}, "expectedArrivalTime": "2024-05-03T17:52:00+02:00", "lineId": "71"}, {"destination": {"fr": "DE BROUCKERE", "nl": "DE BROUCKERE"}, "expectedArrivalTime": "2024-05-03T17:57:00+02:00", "lineId": "71"}]}}, "8232": {"5": {"pointid": "8232", "lineid": "5", "passingtimes": [{"destination": {"fr": "HERRMANN-DEBROUX", "nl": "HERRMANN-DEBROUX"}, "expectedArrivalTime": "2024-05-03T17:54:00+02:00", "lineId": "5"}, {"destination": {"fr": "HERRMANN-DEBROUX", "nl": "HERRMANN-DEBROUX"}, "expectedArrivalTime": "2024-05-03T17:58:00+02:00", "lineId": "5"}]}}}}
Traceback (most recent call last):
  File "/root/stibgtfs2mqtt-main/mqttsensor/sensor.py", line 380, in <module>
    init(clean)
  File "/root/stibgtfs2mqtt-main/mqttsensor/sensor.py", line 257, in init
    setConfig(attribute)
  File "/root/stibgtfs2mqtt-main/mqttsensor/sensor.py", line 333, in setConfig
    mqttSend(c,topic,True)
  File "/root/stibgtfs2mqtt-main/mqttsensor/sensor.py", line 361, in mqttSend
    client = connect_mqtt()
  File "/root/stibgtfs2mqtt-main/mqttsensor/sensor.py", line 357, in connect_mqtt
    client.connect(mqtt_server, int(mqtt_port))
  File "/usr/local/lib/python3.9/dist-packages/paho/mqtt/client.py", line 914, in connect
    return self.reconnect()
  File "/usr/local/lib/python3.9/dist-packages/paho/mqtt/client.py", line 1044, in reconnect
    sock = self._create_socket_connection()
  File "/usr/local/lib/python3.9/dist-packages/paho/mqtt/client.py", line 3685, in _create_socket_connection
    return socket.create_connection(addr, timeout=self._connect_timeout, source_address=source)
  File "/usr/lib/python3.9/socket.py", line 843, in create_connection
    raise err
  File "/usr/lib/python3.9/socket.py", line 831, in create_connection
    sock.connect(sa)
ConnectionRefusedError: [Errno 111] Connection refused

It works now, I was using a wrong mqtt_server adress

Hi again @nxd4n
There is a small typo in the example of config.yaml file.
You have to add a ‘/’ at the end of mqtt_topic: ‘homeassistant/sensor’
If not the sensorstib1234567890 sensor is not published in /homeassistant/sensor directory and so not auto discovered by home assistant.

1 Like

Thanks a lot for developing this tool. I installed it after setting up MQTT in my Home Assistant (I have a docker version running on my raspberry pi) and everything seems to be working ( the tmux suggestion was nice to be able to run the python script continuously).

I remember from an earlier version with API V1 that it was possible to get the arrival time of the 2 next tram/bus/metro at a stop. Is this not possible anymore?

I didn’t manage to automatically install the dependencies using the requirements.txt but used the pip install manually. It installed the latest version 2.1.0 of paho-mqtt and I had to update the line

client = mqtt_client.Client(client_id)

to:

client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION1, client_id)

After that, the script was running and I could add the MQTT sensors to my home assistant dashboard

Hi,
Unfortunately, the new API doesn’t have the easy access to the next 2 arrivals of a line on a given stop. The way to work with the API has completely changed.
But you can find the next passing times in the attributes of the created sensor of the line of the stop.

update:

Hi Daniel (@nxd4n )

I wanted to reach out to you directly, as I started this journey by forking your repository with the intention of submitting a pull request.

However, as I dug deeper into the code, I ended up going much further than initially planned and significantly reworked the integration. Here is a summary of what changed:

API optimisation — the original integration was downloading the entire STIB/MIVB network on every refresh (~1 MB per call). I added server-side filtering using where=pointid in (...) which reduces the payload by a factor of ~600 (from ~1 MB to ~2 KB). Startup was also reworked to use two targeted API calls instead of downloading the full stopsByLine dataset for all lines on the network.

New sensor attributesline_type (bus/tram/metro), line_type_label (B/T/M), line_color and line_text_color sourced from the official STIB GTFS feed, message and is_boarding extracted from the real-time passingtimes field (“Ne pas embarquer”, “Ligne déviée”, “Temps théorique”).

GTFS integration — line colours and vehicle types are downloaded from the STIB GTFS feed at install time and cached, so they are always accurate and up to date without impacting the daily quota.

Options flow improvements — added the ability to remove configured stops (with automatic cleanup of entities and devices in HA), a manual GTFS refresh option, and a reconfigure step to update the API key without losing the stop configuration.

Translations — full French and Dutch translations of the setup and options flow.

CI — GitHub Actions with HACS validation and Hassfest checks.

Given the extent of the changes, I felt a pull request was no longer the right approach — the codebase diverged significantly from the original. I have therefore decided to maintain this as an independent fork going forward, giving credit to your original work in the licence file.

I hope you don’t mind, and I genuinely want to thank you for the solid foundation you built — it was a great starting point.

With sincere regards,
Kamil AvH

here is my repo:

Hi Kamil,

I don’t mind at all, that’s the open source spirit, glad that this helped to improve the integration. Let me fork yours and see if we can improve it even more.

When I started this, the pointid in (…) wasn’t working, I guess they improved the api since then, and yes, the payload is huge without it. I opened some cases with them for this, but didn’t follow up, it was working fine for me for now so, I didn’t bother to check if they improved their syntax.

BTW, I’m using this card:

type: custom:html-template-card
title: Horaires STIB/MIVB
ignore_line_breaks: true
content: >
{% set stop_name = “MAX WALLER” %}

{# Define the GTFS Colors manually here #} {% set line_colors = {
“50”: {“bg”: “B4BD10”, “txt”: “000000”},
“54”: {“bg”: “E43C2E”, “txt”: “FFFFFF”},
“82”: {“bg”: “91BEE7”, “txt”: “000000”}
} %}

<div style="display: flex; align-items: center; margin-bottom: 12px;
  padding-bottom: 8px; border-bottom: 1px solid var(--divider-color);">
    <ha-icon icon="mdi:map-marker" style="color:#2196F3; margin-right: 10px;"></ha-icon>
    <span style="font-weight: bold; font-size: 1.1em;">{{ stop_name }}</span>
  </div>

<table width="100%" style="border-collapse: collapse;">
    <thead>
      <tr style="border-bottom: 1px solid var(--divider-color);">
        <td width="20%" align="left" style="font-weight: bold; padding-bottom: 8px;">Ligne</td>
        <td width="40%" align="center" style="font-weight: bold; padding-bottom: 8px;">Destination</td>
        <td width="40%" align="right" style="font-weight: bold; padding-bottom: 8px;">Passages</td>
      </tr>
    </thead>
    <tbody>
      {% macro render_row(entity_id, line_label) %}
        {% set state_obj = states[entity_id] %}

    {% if state_obj %}
      {% set line_id = state_obj.attributes.line_id %}
      {% set state_1 = state_obj.state %}
      {% set state_2 = state_obj.attributes.next_passage_minutes %}
      {% set dest = state_obj.attributes.destination or 'N/A' %}
      
      {# Get colors from the mapping above, or use fallback #}
      {% set bg = line_colors[line_id].bg if line_id in line_colors else '777777' %}
      {% set txt = line_colors[line_id].txt if line_id in line_colors else 'FFFFFF' %}

      {% set is_available_1 = state_1 not in ['unknown', 'unavailable', 'None', none] %}
      {% set is_available_2 = state_2 not in ['unknown', 'unavailable', 'None', none] %}

      <tr>
        <td width="20%" align="left" style="padding: 10px 0;">
          <span style="background: #{{ bg }}; color: #{{ txt }}; padding: 4px 8px; border-radius: 4px; font-weight: bold; font-size: 1.4em;">
            {{ line_label }}
          </span>
        </td>

        <td width="40%" align="center" style="padding: 6px 0; font-size: 0.9em; text-transform: uppercase;">
          {{ dest }}
        </td>
        
        <td width="40%" align="right" style="padding: 6px 0; font-weight: bold;">
          <span style="font-size: 1.6em;">
            {{ state_1 if is_available_1 else '--' }}
          </span>
          <span style="font-size: 0.8em; margin-right: 4px;">{{ 'm' if is_available_1 }}</span>
          
          <span style="color: var(--secondary-text-color); font-size: 1.2em;"> | </span>
          
          <span style="font-size: 1.2em; color: var(--secondary-text-color);">
            {{ state_2 if is_available_2 else '--' }}
          </span>
          <span style="font-size: 0.7em; color: var(--secondary-text-color);">{{ 'm' if is_available_2 }}</span>
        </td>
      </tr>
    {% endif %}
  {% endmacro %}
  
  {{ render_row('sensor.stib_max_waller_line_50_max_waller_gare_centrale', '50') }}
  {{ render_row('sensor.line_50_max_waller_lot_station', '50') }}
  {{ render_row('sensor.line_82_max_waller_gare_de_berchem', '82') }}
  {{ render_row('sensor.line_82_max_waller_neerstalle', '82') }}
  {{ render_row('sensor.line_54_saint_denis_trone', '54') }}
</tbody>

</table>

let’s see if I can use here your colors attributes.