Best practice for dynamic channels (indexed departures) – Did I do this right?

Hi everyone,

I am currently developing a custom TransitApp Binding for openHAB 5.x that fetches public transit departure schedules for specific stops via the Transit API v4.

A transit stop (stop thing) depends on a central API bridge (bridge thing) and needs to provide multiple departure channels (e.g., routeLongName#1, departureTime#1, isCancelled#1, up to #maxNumDepartures).

During development, I initially tried creating all channels purely at runtime in Java using ChannelBuilder and ThingBuilder. However, this caused issues with textual .things file definitions and UI visibility (channels remained empty/invisible or showed NULL until manual re-initialization).

I have now reworked the implementation into what I believe is the standard openHAB approach, but I would love to get feedback from experienced binding developers to see if this is considered the “clean way” to do it.

My Current Implementation

1. Metadata (OH-INF/thing/thing-types.xml)

Instead of purely dynamic code generation without XML references, I defined base <channel-type> elements and explicitly declared the indexed channel instances inside the <thing-type> up to a reasonable maximum (e.g., 3 departures):

XML

<thing-type id="stop">
    <supported-bridge-type-refs>
        <bridge-type-ref id="bridge"/>
    </supported-bridge-type-refs>
    <label>Transit Stop</label>
    <description>Transit Stop Thing</description>
    <channels>
        <channel id="jsonResponse" typeId="jsonResponse"/>
        <channel id="routeLongName#1" typeId="routeLongName"/>
        <channel id="routeShortName#1" typeId="routeShortName"/>
        <channel id="departureTime#1" typeId="departureTime"/>
        <channel id="isCancelled#1" typeId="isCancelled"/>
        <!-- Repeated up to #3 (or #10) -->
    </channels>
</thing-type>

<channel-type id="routeLongName">
    <item-type>String</item-type>
    <label>Route Long Name</label>
    <description>Long name of the route</description>
    <state readOnly="true"/>
</channel-type>
<!-- Other base channel types... -->

2. Handler Lifecycle & Bridge Sync (TransitAppStopHandler.java)

To prevent the stop thing from failing on startup when the bridge is still initializing (UNKNOWN status), I implemented bridgeStatusChanged(ThingStatusInfo) to asynchronously trigger the data fetch only when the bridge comes ONLINE:

Java

@Override
public void initialize() {
    // ... validate configuration parameters (globalStopId, stopName, etc.) ...

    Bridge bridge = getBridge();
    if (bridge == null) {
        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is missing");
        return;
    }

    if (bridge.getStatus() != ThingStatus.ONLINE) {
        logger.warn("Bridge {} is not online yet. Stop thing {} will wait for bridge.", bridge.getUID(), thing.getUID());
        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not online");
        return;
    }

    fetchStopData();
}

@Override
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
    super.bridgeStatusChanged(bridgeStatusInfo);
    if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
        fetchStopData();
    } else {
        updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not online");
    }
}

3. State Updating (updateDepartureChannels)

Once the JSON payload is retrieved, the states of the pre-defined channels are updated based on the maxNumDepartures config parameter:

Java

private void updateDepartureChannels(String jsonBody, int maxNumDepartures) {
    try {
        // Reset states first
        for (int i = 1; i <= maxNumDepartures; i++) {
            updateState("routeLongName#" + i, new StringType("-"));
            updateState("departureTime#" + i, new StringType("-"));
            updateState("isCancelled#" + i, OnOffType.OFF);
        }

        // Parse JSON and update active indexed channels...
        int deptIndex = 1;
        while (deptIndex <= maxNumDepartures && /* more departures in JSON */) {
            // ... parsing logic ...
            updateState("routeLongName#" + deptIndex, new StringType(parsedLongName));
            updateState("departureTime#" + deptIndex, new StringType(parsedTime));
            updateState("isCancelled#" + deptIndex, isCancelled ? OnOffType.ON : OnOffType.OFF);
            deptIndex++;
        }
    } catch (Exception e) {
        logger.error("Failed to parse departure JSON: {}", e.getMessage(), e);
    }
}

My Questions to the Community:

  1. Static vs. Runtime Dynamic Channels: Is pre-defining indexed channels (like #1, #2, #3) in thing-types.xml the recommended openHAB architecture for this kind of indexed data, or should I go back to using ChannelBuilder / ThingBuilder dynamically in Java? If dynamic building in Java is preferred, how do you handle UI visibility and textual .things file compatibility cleanly?

  2. Bridge Lifecycle: Is overriding bridgeStatusChanged(ThingStatusInfo) the cleanest way to defer child initialization until the bridge is authenticated and ready, or is there a built-in framework mechanism I missed?

  3. General Feedback: Do you see any architectural flaws or violations of openHAB coding guidelines in this snippet?

Thanks in advance for any insights or suggestions!

Best regards

I can’t properly answer this, but I know that there are other bindings that work with dynamic channels. You could peek at e.g. the MQTT binding to see how they handle it. There must be a way to make the UI handle this. The channel types should probably still be defined in the XML though, only the channels themselves should be dynamic.

As far as I know, you don’t need to overriding - because the framework will call initialize() again if an attempt is made to get the Thing online. What you already do in initialize() should therefore suffice as far as I can tell.

I’ll leave that to others. It’s sometimes hard to know exactly what “the guidelines” are - but whatever you do, make sure to apply spotless whenever you have changed the code, so that the code is formatted “correctly”:

 mvn spotless:apply -pl :org.openhab.binding.xxx

Just a quick and partial answer mainly due to possible inappropriate answer by @Nadahar : yes overriding bridgeStatusChanged is in principle a good practice but I did not yet read in details what you put inside.

No inappropriateness intended! I might have been wrong though :wink:

No. this is not the recommended way. For indexed channels it is recommended to use channel-groups. Each indexed channel will be a group that contains the channels. In the thing you repeat those group for each index. Something like:

<channel-groups>
  <channel-group id="depart1" typeId="depart-type"></channel-group>
  <channel-group id="depart2" typeId="depart-type"></channel-group>
  ...
</channel-groups>

To get an idea on how this works there are several bindings that use this. To address such channels in items it looks like depart1#routeLongName. It uses the # here. You also used # in your channel names, and this could explain you had issues with dynamic channels as # is used to identify channel groups and channels.

Static vs. Runtime Dynamic Channels?

Dynamic channels make the binding code more complex. But give more flexibility in the number of channels. We channel groups you only dynamically need to add the group. It depends a bit if you know the number of channel(groups) you want to be available upfront. If this is unknown or you would like to support a lot dynamic channels might be preferred. For the UI/textual files it doesn’t matter much. Because the items are not dynamic. When channels are dynamically the user has to wait in the UI before they become available to create those items. But in textual files the user can already create the items.

  • Do not reset channels first. Because you’ll get 2 state updates for linked channels, which might be annoying when used in rules. Better only reset channels not used (> maxNumDepartures)
  • If you reset better to set to Undef.
  • For departure time better use Number:Time or DateTimeType
  • Use OnOffType.from(isCancelled)
  • Don’t catch Exception, but specific checked exceptions and RuntimeException
  • Only log to error in case it breaks openHAB. Here you probably should log to debug and set the binding to offline with communication error, and the error message text. (And if this is in a repeated scheduled process don’t forget to set the binding online it the next scheduled run succeeds).