@sdetweil
There are no errors or issues in the console for MMM-Pages but what I didn’t see when my module is active is
[MMM-pages] received that all objects are created; will now hide things!
Which is present in the console when I disable it. I notice in the Pages docs it says
MMM-pages doesn't activate until we receive the DOM_OBJECTS_CREATED notification, as that notification ensures all modules have been loaded.
That is obviously not happening when my module is active. it loads the data from the NextBus API asyncronously so I think updateDom() keeps running before the first ready DOM is returned. I manually notifyied MagicMirror that it is ready by adding this into socketNotificationRecieved and seems to work now.
if (!this.hasSentDomCreated) {
this.hasSentDomCreated = true;
this.sendNotification("DOM_OBJECTS_CREATED");
}
There is probably a better way to do this but seems to work.
This is my first attempt at a module so I wasn’t expecting plain sailing but any feedback is always appreciated.
MMM-TravelLine.js
// LCARS Colours
var busColours = [
"#cc99ff", //african-violet
"#ffaa90", //almond
"#ffbbaa", //almond-creme
"#5566ff", //blue
"#8899ff", //bluey
"#ff9966", //butterscotch
"#ffaa00", //gold
"#ff9900", //golden-orange
"#666688", //gray
"#999933", //green
"#99ccff", //ice
"#cc55ff", //lilac
"#cccc66", //lima-bean
"#cc5599", //magenta
"#ff2200", //mars
"#9966ff", //moonlit-violet
"#ff8800", //orange
"#ff8866", //peach
"#cc4444", //red
"#aaaaff", //sky
"#f5f6fa", //space-white
"#ffcc99", //sunflower
"#ff5555", //tomato
"#ddbbff", //violet-creme
// Lower Decks
"#ffeecc", //butter
"#ff9911", //daybreak
"#ffaa44", //harvestgold
"#ffcc99", //honey
"#ff4400", //october-sunset
"#ff7700", //orange
"#cc5500" //rich-pumpkin
]
function newColour () {
var randomNumber = Math.floor(Math.random() * busColours.length);
return randomNumber;
}
const directionColours = {
"Edinburgh, Bus Station": "#cc99ff", //
"Galashiels Transport Interchange": "#ff7700" // blue
};
// fallback colour if direction not in map
const defaultColour = "#cc9bcd";
Module.register("MMM-TravelLine", {
defaults: {
apiBase: 'https://nextbus.mxdata.co.uk/nextbuses/1.0/1',
username: "yourusername",
password: "yourpassword",
// stopIDs[[stop ID, Name for heading, target bus, Random colours or not]]
stopIDs: [ ["36235972","Princes St West (PD)","34",true],
["36232486","The Mound (MD)","27",true]],
//targetBus: "X62", // Service we’re interested in
updateFrequency: 10, // Refresh time in minutes
maxResults: 10,
debug: false,
},
getStyles() {
return ["travelline.css"];
},
start() {
// some dummy values
this.timesReturned = [
{aimed: "..." ,service: "Error", direction: "...", raw: ""},
{aimed: "..." ,service: "Error", direction: "...", raw: ""},
{aimed: "..." ,service: "Error", direction: "...", raw: ""}
];
this.sendSocketNotification("FETCH_ROUTES", {
username:this.config.username,
password:this.config.password,
apiBase:this.config.apiBase,
stopIDs:this.config.stopIDs,
//targetBus:this.config.targetBus,
timestamp: new Date().toISOString(), // Build a fresh ISO8601 timestamp
});
// Fetch new routes at the interval set in the config
setInterval(() => this.sendSocketNotification("FETCH_ROUTES", {
username:this.config.username,
password:this.config.password,
apiBase:this.config.apiBase,
stopIDs:this.config.stopIDs,
//targetBus:this.config.targetBus,
timestamp: new Date().toISOString(), // Build a fresh ISO8601 timestamp
}), this.config.updateFrequency * 60 * 1000);
// Refresh the display every 30 seconds
setInterval(() => this.updateDom(), 30000);
},
notificationReceived(notification, payload) {
if (notification === "UPDATED_ROUTES") {
this.timesReturned = payload;
//this.updateDom();
}
},
socketNotificationReceived: function (notification, payload) {
if (notification === "UPDATED_ROUTES") {
this.timesReturned = payload;
this.updateDom();
// Notify once that DOM objects exist
if (!this.hasSentDomCreated) {
this.hasSentDomCreated = true;
this.sendNotification("DOM_OBJECTS_CREATED");
}
}
},
getDom() {
const container = document.createElement('div');
container.className = 'travelline-container';
container.style.display = 'flex'; // show tables side-by-side
container.style.gap = '20px'; // spacing between tables
//console.log("timesReturned is: ",this.timesReturned)
// show message until we have data
if (!Array.isArray(this.timesReturned) || this.timesReturned.length === 0) {
container.innerHTML = "<div class='no-data'>Loading bus times…</div>";
return container;
}
// loop through each stop’s departures
this.timesReturned.forEach((stopResults, stopIndex) => {
const resultsArray = Array.isArray(stopResults) ? stopResults : [];
// create a wrapper for this stop
const stopWrapper = document.createElement('div');
stopWrapper.className = 'stop-wrapper';
// optional: heading for the stop
const heading = document.createElement('h3');
heading.className = "travelline-heading";
heading.textContent = `${resultsArray[0].stopName}`; //heading.textContent = `Stop ${stopIndex + 1}`;
//heading.style.textAlign = 'center';
stopWrapper.appendChild(heading);
//if (!(stopResults[stopIndex].direction == "No Departures")) {
// create table
const table = document.createElement('table');
table.className = 'travelline-table';
// limit results
const limitedResults = resultsArray.slice(0, this.config.maxResults);
limitedResults.forEach(item => {
if (!(item.direction == "No Departures")) {
const row = document.createElement('tr');
row.className = 'travelline-row';
if (item.randomColour){
const colourIndex = newColour();
row.style.color = busColours[colourIndex];
} else {
// Pick a fixed colour based on destination direction
const dir = item.direction.trim();
row.style.color = directionColours[dir] || defaultColour;
}
// minutes-to-departure
const minutesCell = document.createElement('td');
minutesCell.className = 'travelline-minutes';
const depTime = new Date(item.raw);
const diffMs = depTime - Date.now();
const diffMin = Math.max(0, Math.round(diffMs / 60000));
if (diffMin >= 60) {
const hours = Math.floor(diffMin / 60);
const mins = diffMin % 60;
minutesCell.textContent = `(${hours}h ${mins}m)`;
} else {
minutesCell.textContent = `(${diffMin} m)`;
}
// aimed departure time
const timeCell = document.createElement('td');
timeCell.className = 'travelline-time';
timeCell.textContent = item.aimed;
// service
const serviceCell = document.createElement('td');
serviceCell.className = 'travelline-service';
serviceCell.textContent = item.service;
// direction
const directionCell = document.createElement('td');
directionCell.className = 'travelline-direction';
directionCell.textContent = "To " + item.direction.replace(/,/g, "").split(" ")[0];
// assemble row
row.appendChild(minutesCell);
row.appendChild(timeCell);
row.appendChild(serviceCell);
row.appendChild(directionCell);
table.appendChild(row);
} else {
const row = document.createElement('tr');
row.className = 'travelline-row';
//Set row to a random colour
const colourIndex = newColour();
row.style.color = busColours[colourIndex];
// direction - should display'No Departures for the '
const directionCell = document.createElement('td');
directionCell.className = 'travelline-direction';
directionCell.textContent = item.direction + " for the ";
// service
const serviceCell = document.createElement('td');
serviceCell.className = 'travelline-service';
serviceCell.textContent = item.service;
// assemble row
row.appendChild(directionCell);
row.appendChild(serviceCell);
table.appendChild(row);
}
}); //limited results end
stopWrapper.appendChild(table);
container.appendChild(stopWrapper);
});
return container;
}
});
node_helper.js
const NodeHelper = require("node_helper");
//const fetch = require("node-fetch"); // Ensure fetch is available
const { URL } = require("url"); // Ensure URL is available
const { parseStringPromise } = require("xml2js");
module.exports = NodeHelper.create({
async socketNotificationReceived(notification, payload) {
console.log(`There are ${payload.stopIDs.length} stop IDs in the array`);
if (notification === "FETCH_ROUTES") {
try {
const authHeader = "Basic " + Buffer.from(`${payload.username}:${payload.password}`).toString("base64");
const siriBodies = this.buildSiriBodyArray(payload.stopIDs, payload.username, payload.timestamp);
// store results for all stops
const allResults = [];
// loop over each stop body
for (const [index, siriBody] of siriBodies.entries()) {
//console.log("siriBody is: ", siriBody[0]);
try {
const response = await fetch(payload.apiBase, {
method: "POST",
headers: {
"Content-Type": "application/xml",
"Authorization": authHeader
},
body: siriBody
});
if (!response.ok) {
console.error(`Fetch failed for stop ${payload.stopIDs[index][0]}: ${response.status}`);
continue;
}
const xmlText = await response.text();
const result = await parseStringPromise(xmlText, { explicitArray: false });
// extract departures for this stop. stopIDs [stopID, StopName, target bus, randomColours]
let times = this.getTimes(result, payload.stopIDs[index]);
console.log("times is", times)
if (times[0] === "No Departures"){
console.log("Times returned and no departures or target bus not found");
const theService = times[1]
const theStop = times[2];
times = [{
service: theService,
direction: 'No Departures',
aimed: 0,
raw: 0,
stopID: '',
stopName: theStop,
randomColour: true
}];
}
allResults.push(times); // Removed spread operator to seperate the data from each stop
} catch (err) {
console.error(`Error fetching stop ${payload.stopIDs[index][0]}:`, err);
}
}
// finally send combined result
//console.log(allResults);
this.sendSocketNotification("UPDATED_ROUTES", allResults);
} catch (error) {
console.error("General fetch error:", error);
}
}
},
getTimes: function(receivedData, stopID) {
const departures = receivedData?.Siri?.ServiceDelivery?.StopMonitoringDelivery?.MonitoredStopVisit;
if (!departures){
console.log("No departures found at this stop");
return ["No Departures",stopID[2], stopID[1]]; // return 'No Departures', bus name, stop name
}
const departuresArray = Array.isArray(departures) ? departures : [departures];
const myBus = departuresArray.filter(d => d.MonitoredVehicleJourney?.PublishedLineName === stopID[2]); //target);
if (myBus.length === 0) {
console.log("No target bus found at this stop");
return ["No Departures",stopID[2], stopID[1]]; // return 'No Departures', bus name, stop name
}
return myBus.map(d => {
const call = d.MonitoredVehicleJourney.MonitoredCall;
const rawTime = call.ExpectedDepartureTime || call.AimedDepartureTime;
const dateObj = new Date(rawTime);
const isValidDate = rawTime && !isNaN(dateObj.getTime());
const friendlyTime = isValidDate
? dateObj.toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit'
})
: "N/A";
return {
service: stopID[2], // target bus,
direction: d.MonitoredVehicleJourney.DirectionName,
aimed: friendlyTime,
raw: rawTime,
stopID: stopID[0], // stopID
stopName: stopID[1], // Description or stop name for the table heading
randomColour: stopID[3] // Random colour
};
});
},
buildSiriBodyArray: function(stopIDs, username, timestamp){
if (stopIDs.length == 0) {
console.log("The stopID array is empty");
return undefined;
};
const siriBodys = [];
stopIDs.forEach((item, index) =>{
siriBodys.push(`<?xml version="1.0" encoding="UTF-8"?>
<Siri version="1.0" xmlns="http://www.siri.org.uk/">
<ServiceRequest>
<RequestTimestamp>${timestamp}</RequestTimestamp>
<RequestorRef>${username}</RequestorRef>
<StopMonitoringRequest version="1.0">
<RequestTimestamp>${timestamp}</RequestTimestamp>
<MessageIdentifier>msg-001</MessageIdentifier>
<MonitoringRef>${item[0]}</MonitoringRef>
</StopMonitoringRequest>
</ServiceRequest>
</Siri>`);
});
return siriBodys;
}
});