With this workflow, every morning when I need to go to work, a widget on my smart home dashboard shows the waiting time for the next ferry departure, obtained from the official PDF schedule on the operator's website. Additionally, it monitors the municipality's official Telegram channel for any service interruptions due to weather conditions and notifies me both on the dashboard and via the voice assistant.
The entire process is optimized to minimize the use of AI and REST API calls.
It uses n8n, Open AI, Home Assistant, Telegram, MQTT, REST API, Jinja2 and a bit of javascript programming.
On weekdays when I work on-site instead of remotely, I use home automation to get updated travel times for reaching work, whether by car or scooter, or using public transport (a ferry... Exactly: going to work by boat is the best way to start the day!). And this information, managed by Home Assistant, is displayed on a dynamic dashboard available in key areas of the house and accessible via voice assistants.

Getting the travel times for the car is very straightforward using, for example, the Waze Travel Time integration in Home Assistant... It’s so simple that it’s not worth covering here. For public transport, however, there are several ways to retrieve the daily schedule or directly the departure time of the next public transport:
This is the suggested method when the public transport operator directly provides its own set of APIs, but I won't cover it here because it is strictly tied to the specific case. Therefore, the API documentation of the transport operator must be consulted, and a RESTful sensor will likely need to be configured according to the official Home Assistant guide.
This method is perhaps the simplest, most effective, and suitable for almost any purpose, as most public transport is now recognized by Google Maps. It is also discussed in great detail in the documentation for the Google Maps Travel Time integration, including the guide to obtain the API key from Google, so I don't think a further in-depth study is needed. The guide also includes tips on how to optimize the use of the sensor without exceeding the free API usage quota and incurring charges.
You can use this method If Google Maps doesn't cover your local public transport (for example the ferry I'm using) but it has the drawback that you need to work with HTML and CSS, and it is especially dependent on the structure of the page. Therefore, if the page changes in the future, the sensor will need to be reconfigured.
This is the method I will cover here... And not only that! I’ve also included real-time updates on the status of departures and services, as well as optimizations to minimize the use of APIs and AI to what is strictly necessary (resulting in cost reduction). So let's get started!
In any case, even if it were possible to directly obtain the departure time of the next transport, for services with frequent departure times but schedules that do not change often, it might still make sense to retrieve the daily schedule only once a day. This approach would avoid excessive use of APIs and the risk of hitting limits or paying for exceeding free usage quotas. Using this sensor, you can then access this static information to determine the next departure time.
The workflow is divided in three parts, in order to optimize all the needed resources and minimize the use of Open AI API calls:
The first part ensures that the available schedule is always up-to-date, notifying of any errors regarding the unavailability of the source document if necessary.
The second part provides the daily schedule and calculates the remaining time until the next departure.
The third part monitors the municipality's Telegram channel for real-time notifications of any service cancellations due to adverse weather and sea conditions.
For this process, again, I used n8n: n8n is an open-source workflow automation tool that allows you to connect various applications and services to automate repetitive tasks without manual intervention. It provides a visual interface where you can design workflows by linking different nodes that represent actions, triggers, or data processing steps.
I installed it in a Proxmox LXC by using the helper script provided by Community-Scripts: this is a very useful Community with many script that will help you many times: if you like them, consider donating to support Angie, tteckster's wife - the founder and best supporter of the community - too early passed away.
We will use a GPT LLM to retrieve all those pieces of information that are unstructured and in a variable format. I used Open AI GPT models but you can choose other AI LLMs: regardless of which LLM you use, to use it from another software, like n8n, it is necessary to use the exposed APIs and to do so, an authorised token must be defined. These are the steps to create it with OpenAI:
You need this only inf you want to use Telegram as notification service for both error and update of the schedule; this is not mandatory since you can use the notification service that you prefer... like for example Ntfy or Gotify. You can also decide to use only Home Assistant: in this case you can replace the Telegram message with a MQTT message with the error description, then define an Home Assistant sensor that listen to that topic and create an Home Assistant automation to send a notification with the Companion App if an error occours. You will find later an example on how to publish a MQTT message with n8n and how to define a MQTT sensor in Home Assistant.
Creating a Telegram Bot is really really simple:
/newbot[Telegram_Token]): save it securely and we'll use it later to configure the Telegram account for the workflowThe schedule for my ferry is provided in a PDF on the operator's website. Since the schedule doesn’t change very often, it doesn’t make sense to frequently query the site, repeatedly download the same file, and rely on AI to extract the same information over and over, with the real risk of quickly exceeding the free usage quota and increasing costs. Therefore, the first part of the workflow focuses on optimizing the schedule generation.
Daily Schedule Trigger:
Retrieve saved Schedule
Read JSON File from Disk Reads a previously saved JSON file (/nas/delfinoverde.json) containing the last known ferry schedule. I saved it in ashared folder but It's not mandatory since the file is used only by this set of workflows. For the first time, you can provide and empty file with this JSON content:
[{
"output": {
"feriali":[],
"festivi":[],
"data cambio orario":"2025-01-10"
}
}]
feriali means "working days" while festivi "holidays" and "non-working days"data cambio orario is the date of the schedule change (the first time set it to current day in order to trigger the update), in this case between the summer and winter timetable. If you have a single timetable or a different schedule management, you can modify this part (and the ones that are related to this later) according to your needs.Convert Binary to JSON Converts the binary data from the JSON file into a usable JSON format for comparison or validation.Check If Schedule Change Date
data cambio orario) from the existing data and if true proceeds with downloading the updated scheduleGet Updated Schedule Download the PDF Schedule, Convert PDF to JSON
Get PDF downloads the ferry schedule in PDF format from a specified URL while Convert PDF to JSON converts the binary data into a usable JSON format for further processing. Extract Departures Using OpenAI
Search for departures uses an OpenAI GPT-4O-MINI model to process the text from the PDF and extract:
Extract from {{ $json.text }} all the departing from Muggia,
dividing by working and no working days and indicating also the arrival time to Trieste.
Return also, if specified, the next date when the schedule will change in ISO formatStructured Output Parser parses the structured output from the OpenAI model to ensure the results are in a proper JSON format, as defined by the schema:
{
"feriali": [{ "Partenza": "", "Arrivo": "" }],
"festivi": [{ "Partenza": "", "Arrivo": "" }],
"data cambio orario": ""
}Convert and Save to JSON File
Convert to JSON file: Converts the structured data into a JSON file format.Write JSON file to Disk: Saves the updated schedule to /nas/delfinoverde.json.Schedule Update Notification and Error Handling
Notify Schedule update sends a Telegram message notifying users that the schedule has been updated.Notify Error sends a Telegram message with the description of the error occoured during the PDF downloading or processing (for example in case of missing file)[Chat_ID] is the ID of the chat with your Telegram Bot (or a group where the Bot is an administrator): you can retrieve it by adding a Telegram Trigger and posting a test message.At the end, if everything worked, you will have a /nas/delfinoverde.json like the following one... and so you're ready for the next part.
[
{
"output": {
"feriali":[
{"Partenza":"7:15","Arrivo":"7:45"},
{"Partenza":"8:25","Arrivo":"8:55"},
{"Partenza":"9:35","Arrivo":"10:05"},
{"Partenza":"10:45","Arrivo":"11:15"},
{"Partenza":"11:55","Arrivo":"12:25"},
{"Partenza":"14:35","Arrivo":"15:05"},
{"Partenza":"15:45","Arrivo":"16:15"},
{"Partenza":"16:55","Arrivo":"17:25"},
{"Partenza":"18:05","Arrivo":"18:35"},
{"Partenza":"20:05","Arrivo":"20:35"}
],
"festivi":[
{"Partenza":"10:45","Arrivo":"11:15"},
{"Partenza":"11:55","Arrivo":"12:25"},
{"Partenza":"14:15","Arrivo":"14:45"},
{"Partenza":"15:45","Arrivo":"16:15"},
{"Partenza":"17:15","Arrivo":"17:45"},
{"Partenza":"18:45","Arrivo":"19:15"}
],
"data cambio orario":"2025-03-12"}
}
]
Now that we have a JSON with the current schedule, it would have been enough to expose an API that reads the file and returns the time of the next departure. The following workflow does exactly that, but with two particular considerations:
Schedule Trigger, Schedule Webhook
Static Data Initialization
jsonSchedule: the ferry schedule.lastExecution: the timestamp of the last workflow execution.Time-Based Logic (Date & Time and If Nodes)
lastExecution.Update Static Data
If node returns true, meaniong that cached data are empty or expired, this part of the flow:
Determine Next Departure (Get Daily Schedule and Next Departure)
function isWorkingDay() {
const now = new Date();
// Check if today is Sunday
if (now.getDay() === 0) {
return false;
}
// Define national and local holidays (fixed dates)
const holidays = [
'01-01',
'01-06',
'04-25',
'05-01',
'06-02',
'08-15',
'11-01',
'11-03',
'12-08',
'12-25',
'12-26'
];
// Check for Easter Monday (variable date)
const year = now.getFullYear();
const easterMonday = getEasterMonday(year);
// Format today's date as MM-DD
const today = now.toISOString().slice(5, 10);
// Check if today is a fixed holiday or Easter Monday
if (holidays.includes(today) || (easterMonday.toISOString().slice(5, 10) === today)) {
return false;
}
return true;
}
// Helper function to calculate Easter Monday
function getEasterMonday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
// Return Easter Monday date
return new Date(year, month - 1, day + 1);
}
const workflowStaticData = $getWorkflowStaticData('global');
const currentSchedule = isWorkingDay() ? workflowStaticData.jsonSchedule.feriali : workflowStaticData.jsonSchedule.festivi;
const now = new Date();
const currentTimeInMinutes = now.getHours() * 60 + now.getMinutes();
// Find the next boat
const nextBoat = currentSchedule.find(boat => {
const [hours, minutes] = boat.Partenza.split(":").map(Number);
const departureTimeInMinutes = hours * 60 + minutes;
return departureTimeInMinutes > currentTimeInMinutes;
});
var next;
if (!nextBoat) {
next = "Nessuno";
}
else {
const [nextBoatHours, nextBoatMinutes] = nextBoat.Partenza.split(":").map(Number);
const nextBoatTimeInMinutes = nextBoatHours * 60 + nextBoatMinutes;
const minutesToNextBoat = nextBoatTimeInMinutes - currentTimeInMinutes;
next = minutesToNextBoat;
}
return { "Next": {"Prossimo Delfino Verde": next },
"Schedule" : currentSchedule.map(schedule => schedule.Partenza) }
isWorkingDay Function determines whether today is a working day.
getDay() to see if the current day is Sunday (0). If so, returns false.holidays contains the list of national and local holidays in MM-DD format, such as 01-01 (New Year's Day) and 12-25 (Christmas).getEasterMonday helper function.false. Otherwise, it returns true.getEasterMonday Helper Function calculates the date of Easter Monday for a given year, by implementing the "Computus" algorithm to determine the date of Easter and the adding one day to the calculated Easter date to find Easter Monday.
Main Logic:
isWorkingDay to decide whether to use the feriali (working days) or festivi (holidays/weekends) schedule from workflowStaticData.jsonSchedule.currentSchedule) for the first departure time (Partenza) later than the current time."Nessuno". Otherwise, calculates the time difference (in minutes) between the current time and the next departure.Next: Contains the time (in minutes) until the next departure or "Nessuno" (no one) if no more departures are available.Schedule: An array of all departure times (Partenza) for the selected schedule.Example Output
{
"Next": {"Prossimo Delfino Verde": 45},
"Schedule": ["15:15", "16:00", "16:45", "17:30"]
}{
"Next": {"Prossimo Delfino Verde": "Nessuno"},
"Schedule": ["10:00", "12:00", "14:00", "16:00"]
}Respond to Webhook
MQTT Integration
["10:45","11:55","14:15","15:45","17:15","18:45"]) to an MQTT topic (n8n/DelfinoVerde/schedule), making the data available to other systems or devices.
This in my opinion is the most interesting part: what happens if the service is canceled due to adverse weather and sea conditions? Another sensor monitors the service status by listening to the municipality's Telegram channel to detect any related announcements. The peculiarities of this part are as follows:
Here is the workflow:
Initialize Static Data
Retrieve Telegram Messages
Get Telegram Messages node fetches the content of the Telegram channel (https://t.me/s/comunemuggia) using an HTTP request to retrieve the HTML of the channel's web page.Extract Relevant HTML Content
Extract Telegram Messages node parses the HTML to extract all messages from the channel. It uses a CSS selector to target Telegram messages (div.tgme_widget_message_wrap).Split Messages
Split Messages node processes the extracted messages one by one, allowing further operations on individual messages.Filter Relevant Messages
Filter Today's Delfino Verde Related Messages node isolates messages that:
Keep the Latest Message
Keep Last Message node ensures that only the most recent relevant message is kept for further analysis.Analyze Message Content
Extract Message Content node extracts the text body of the relevant message using a specific CSS selector targeting the message text.Compare with Static Data
ForceRefresh) is requested, it proceeds with further processing (useful for debugging purposes).Determine service Status
Get Delfino Verde Status node uses OpenAI's GPT model to analyze the message text and determine whether the "Delfino Verde" service is operational. The output is a boolean (true for operational, false otherwise). Here is the prompt used:
Analizzando il testo "{{ $json.text }}" determina se il servizio di navigazione
chiamato "Delfino Verde" è operativo o meno e restituisci 'operativo': true o falseUpdate Static Data
Update Static Data node updates the global static data with the latest message and its determined status.Handle Default Status
Reset Default Status node ensures the status is reset to the default value (operativo).Output Status
operativo or soppresso) to an MQTT topic for integration with other systems. Check retain flag in order to allow the broker to retain the last message in case a subscriber connects after the publication or after a broker restart).
We need three sensors (although I later added a fourth one that combines two of them to simplify and make the dashboard display more readable):
Sensors definition:
mqtt:
sensor:
- name: "Delfino Verde Schedule"
state_topic: "n8n/DelfinoVerde/schedule"
force_update: true
- name: "Delfino Verde Status"
state_topic: "n8n/DelfinoVerde/status"
force_update: true
force_update: true sends update events even if the value hasn’t changedRESTful command for on-demand updates:
rest_command:
fetch_delfinoverde_schedule:
url: "[WebHook_URL]"
method: GET
headers:
Content-Type: application/json
timeout: 10
fetch_delfinoverde_status:
url: "[WebHook_URL]"
method: GET
headers:
Content-Type: application/json
timeout: 10
[WebHook_URL] according to your node specification (each WebHook has its own URL).fetch_delfinoverde_schedule to trigger a new pubblication by calling the n8n exposed WebHook. You can use it also if you want to keep the update logic only in one place and so replace the Schedule Trigger of Step 5 with an Automation in Home Assistant.Automation for constant on-demand status updates:
automation:
- id: Check_DelfinoVerde_Status
alias: "Check the status of the 'Delfino Verde' service"
description: ""
triggers:
- trigger: time_pattern
minutes: "/1"
condition:
- condition: state
entity_id: binary_sensor.ready_to_go_to_work
state: "on"
actions:
- action: rest_command.fetch_delfinoverde_status
data: {}
mode: single
fetch_delfinoverde_status command every minute only if a binary sensor binary_sensor.ready_to_go_to_work in on. sensor.delfino_verde_schedule you will find in the first line of the state template is the firse sensor we defined just above.This sensor provides a human-readable version of two other sensors. It works as follows:
nessuno)Essentially, it presents a simplified, easily understandable output based on the current service status and departure times, useful to be printed on a dashboard.
template:
- sensor:
- unique_id: next_delfino_verde_hrv
name: "Prossimo Delfino Verde HRV"
state: >
{% set nextmin = states('sensor.prossimo_delfino_verde') %}
{% set status = states('sensor.delfino_verde_status') %}
{% if status == 'soppresso' %}
{{ status }}
{% elif nextmin == -1 %}
nessuno
{% else %}
{{ nextmin }} min
{% endif %}

This is an optional but very useful step, as the sooner you receive the notification that the transport service has been canceled, the sooner you can decide on a 'Plan B'. In my case, the most immediate way to receive a notification when I’m at home is through the voice assistant. I use Alexa, and it can be configured as a media player, through this Alexa Media Player Integration, to which I refer for installation and setup.
Here is the automation I use:
automation:
- alias: DelfinoVerde Suspended Notification
id: cca47492-3257-470f-89af-debf2569b6be
triggers:
- trigger: state
entity_id: sensor.delfino_verde_status
to: soppresso
condition:
- condition: state
entity_id: binary_sensor.at_home
state: "on"
actions:
- action: notify.alexa_media
data:
message: "Attenzione: Delfino Verde soppresso"
target:
- media_player.echo_dot_cucina
- media_player.echo_dot_bagno
- media_player.echo_dot_ingresso
data:
type: tts
As you can see, it uses a condition to execute the automation only if I am at home, and in that case, it plays the warning message "service canceled" through the Echo Dot devices in the main rooms simultaneously.
Even if I'll try to keep all this pages updated, products change over time, technologies evolve... so some use cases may no longer be necessary, some syntax may change, some technologies or products may no longer be available. Remember to make a backup before modifying configuration files and consult the official documentation if any concept is unclear or unfamiliar.
Use this guide under your own responsibility.
This work and all the contents of this website are licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License (CC BY-NC-SA 4.0).
You can distribute, remix, adapt, and build upon the material in any medium or format, for noncommercial purposes only by giving credit to the creator. Modified or adapted material must be licensed under identical terms.
You can find the full license terms here