diff --git a/.prettierignore b/.prettierignore index 0315d69371..06ad53dbac 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,5 @@ /coverage package-lock.json **.ics +**/README.md +docs/ai-generated/** diff --git a/admin.html b/admin.html new file mode 100644 index 0000000000..776aed6912 --- /dev/null +++ b/admin.html @@ -0,0 +1,455 @@ + + + + + + MagicMirror Admin + + + +
+

MagicMirror Admin

+
+
+ +
+ + Enabled +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + + +
+
+ +
+ +
+ + Disabled +
+
+ + + + +
+ +
+ +
+ + + + diff --git a/config/config.js b/config/config.js new file mode 100644 index 0000000000..3598b29480 --- /dev/null +++ b/config/config.js @@ -0,0 +1,157 @@ +/* Config Sample + * + * For more information on how you can configure this file + * see https://docs.magicmirror.builders/configuration/introduction.html + * and https://docs.magicmirror.builders/modules/configuration.html + * + * You can use environment variables using a `config.js.template` file instead of `config.js` + * which will be converted to `config.js` while starting. For more information + * see https://docs.magicmirror.builders/configuration/introduction.html#enviromnent-variables + */ +let config = { + address: "0.0.0.0", // Address to listen on, can be: + // - "localhost", "127.0.0.1", "::1" to listen on loopback interface + // - another specific IPv4/6 to listen on a specific interface + // - "0.0.0.0", "::" to listen on any interface + // Default, when address config is left out or empty, is "localhost" + port: 8080, + basePath: "/", // The URL path where MagicMirror² is hosted. If you are using a Reverse proxy + // you must set the sub path here. basePath must end with a / + ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], // Set [] to allow all IP addresses + // or add a specific IPv4 of 192.168.1.5 : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.1.5"], + // or IPv4 range of 192.168.3.0 --> 192.168.3.15 use CIDR format : + // ["127.0.0.1", "::ffff:127.0.0.1", "::1", "::ffff:192.168.3.0/28"], + + useHttps: false, // Support HTTPS or not, default "false" will use HTTP + httpsPrivateKey: "", // HTTPS private key path, only require when useHttps is true + httpsCertificate: "", // HTTPS Certificate path, only require when useHttps is true + + language: "en", + locale: "en-US", // this variable is provided as a consistent location + // it is currently only used by 3rd party modules. no MagicMirror code uses this value + // as we have no usage, we have no constraints on what this field holds + // see https://en.wikipedia.org/wiki/Locale_(computer_software) for the possibilities + + logLevel: ["INFO", "LOG", "WARN", "ERROR"], // Add "DEBUG" for even more logging + timeFormat: 12, + units: "imperial", + + modules: [ + { + module: "alert", + }, + { + module: "updatenotification", + position: "top_bar" + }, + { + module: "admininfo", + position: "top_bar" + }, + { + module: "compliments", + position: "top_bar", + config: { + remoteFile: "../../config/custom_compliments.json" + } + }, + { + module: "newsfeed", + position: "top_bar", + config: { + feeds: [ + { + title: "New York Times", + url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml" + } + ], + showSourceTitle: true, + showPublishDate: true, + broadcastNewsFeeds: true, + broadcastNewsUpdates: true + } + }, + { + module: "clock", + position: "top_left" + }, + { + module: "calendar", + header: "US Holidays", + position: "top_left", + config: { + calendars: [ + { + fetchInterval: 7 * 24 * 60 * 60 * 1000, + symbol: "calendar-check", + url: "https://ics.calendarlabs.com/76/mm3137/US_Holidays.ics" + } + ] + } + }, + { + module: "calendar", + header: "Family", + position: "top_left", + config: { + calendars: [ + { + fetchInterval: 7 * 24 * 60 * 60 * 1000, + symbol: "calendar-check", + url: "" + } + ] + } + }, + { + module: "weather", + position: "top_right", + config: { + weatherProvider: "weathergov", + type: "current", + lat: 28.600271, + lon: -81.337884 + } + }, + { + module: "weather", + position: "top_right", + header: "Weather Forecast", + config: { + weatherProvider: "weathergov", + type: "forecast", + lat: 28.600271, + lon: -81.337884 + } + }, + { + module: "traffic", + position: "top_center", + header: "Traffic", + config: { + apiKey: "", + origin: "", + destination: "", + updateInterval: 5 * 60 * 1000, // 5 minutes + units: "imperial"// metric or imperial + } + }, + { + module: "MMM-Wallpaper", + position: "fullscreen_below", + config: { + source: "bing", + slideInterval: 5 * 60 * 1000, // Change slides every 5 minutes + updateInterval: 60 * 60 * 1000, // Check for new wallpapers every hour + maximumEntries: 10, + caption: true, + crossfade: true, + orientation: "auto" + } + }, + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { module.exports = config; } \ No newline at end of file diff --git a/config/config.js.sample b/config/config.js.sample index 1526b0f60b..e6f2f51ebc 100644 --- a/config/config.js.sample +++ b/config/config.js.sample @@ -9,7 +9,7 @@ * see https://docs.magicmirror.builders/configuration/introduction.html#enviromnent-variables */ let config = { - address: "localhost", // Address to listen on, can be: + address: "0.0.0.0", // Address to listen on, can be: // - "localhost", "127.0.0.1", "::1" to listen on loopback interface // - another specific IPv4/6 to listen on a specific interface // - "0.0.0.0", "::" to listen on any interface @@ -34,8 +34,8 @@ let config = { // see https://en.wikipedia.org/wiki/Locale_(computer_software) for the possibilities logLevel: ["INFO", "LOG", "WARN", "ERROR"], // Add "DEBUG" for even more logging - timeFormat: 24, - units: "metric", + timeFormat: 12, + units: "imperial", modules: [ { @@ -63,6 +63,20 @@ let config = { ] } }, + { + module: "calendar", + header: "Family", + position: "top_left", + config: { + calendars: [ + { + fetchInterval: 7 * 24 * 60 * 60 * 1000, + symbol: "calendar-check", + url: "" + } + ] + } + }, { module: "compliments", position: "lower_third" @@ -71,10 +85,21 @@ let config = { module: "weather", position: "top_right", config: { - weatherProvider: "openmeteo", + weatherProvider: "weathergov", type: "current", - lat: 40.776676, - lon: -73.971321 + lat: 28.600271, + lon: -81.337884 + } + }, + { + module: "weather", + position: "top_right", + header: "Weather Hourly", + config: { + weatherProvider: "weathergov", + type: "hourly", + lat: 28.600271, + lon: -81.337884 } }, { @@ -82,10 +107,10 @@ let config = { position: "top_right", header: "Weather Forecast", config: { - weatherProvider: "openmeteo", + weatherProvider: "weathergov", type: "forecast", - lat: 40.776676, - lon: -73.971321 + lat: 28.600271, + lon: -81.337884 } }, { @@ -104,6 +129,22 @@ let config = { broadcastNewsUpdates: true } }, + { + module: "admininfo", + position: "bottom_bar" + }, + { + module: "traffic", + position: "top_right", + header: "Traffic", + config: { + apiKey: "", + origin: "", + destination: "", + updateInterval: 5 * 60 * 1000, // 5 minutes + units: "imperial" // metric or imperial + } + }, ] }; diff --git a/config/custom_compliments.json b/config/custom_compliments.json new file mode 100644 index 0000000000..bc80df9a3e --- /dev/null +++ b/config/custom_compliments.json @@ -0,0 +1,528 @@ +{ + "anytime": [ + "You've got this!", + "You're doing great!", + "Keep up the amazing work!", + "You're capable of amazing things!", + "Believe in yourself!", + "You're stronger than you think!", + "Every day is a fresh start!", + "You're making progress!", + "Stay positive and keep moving forward!", + "You're on the right track!", + "Your hard work will pay off!", + "You're awesome just the way you are!", + "Keep pushing forward!", + "You've overcome challenges before, you can do it again!", + "Your future is bright!", + "You're making a difference!", + "Your efforts are noticed and appreciated!", + "You have the power to create change!", + "Every step forward is progress!", + "You're building something great!", + "Your persistence is inspiring!", + "You're growing every single day!", + "Your courage is admirable!", + "You're exactly where you need to be!", + "Your potential is unlimited!", + "You're doing better than you think!", + "Your kindness makes a difference!", + "You're a problem solver!", + "Your creativity shines through!", + "You're resilient and strong!", + "Your positive attitude is contagious!", + "You're learning and growing!", + "Your determination will take you far!", + "You're making smart choices!", + "Your energy is inspiring!", + "You're handling this beautifully!", + "Your progress is impressive!", + "You're turning obstacles into opportunities!", + "Your smile brightens the world!", + "You're a ray of sunshine!", + "Your dedication is remarkable!", + "You're becoming the best version of yourself!", + "Your journey is unique and valuable!", + "You're inspiring others around you!", + "Your strength amazes me!", + "You're creating your own success story!", + "Your optimism is refreshing!", + "You're capable of more than you know!", + "Your efforts are building something meaningful!", + "You're turning dreams into reality!", + "Your passion is evident!", + "You're a natural leader!", + "Your wisdom shows in your actions!", + "You're making the world a better place!", + "Your compassion touches hearts!", + "You're a beacon of hope!", + "Your authenticity is beautiful!", + "You're writing your own success story!", + "Your resilience is remarkable!", + "You're a force to be reckoned with!", + "Your positivity is powerful!", + "You're creating positive change!", + "Your spirit is unbreakable!", + "You're a true inspiration!", + "Your growth is evident!", + "You're mastering your craft!", + "Your dedication pays off!", + "You're a champion in the making!", + "Your hard work is paying dividends!", + "You're exceeding expectations!", + "Your commitment is unwavering!", + "You're a shining example!", + "Your progress is steady and sure!", + "You're building momentum!", + "Your focus is laser-sharp!", + "You're on an upward trajectory!", + "Your efforts are compounding!", + "You're a success story in progress!", + "Your determination is unstoppable!", + "You're creating opportunities!", + "Your vision is clear!", + "You're making it happen!", + "Your persistence is paying off!", + "You're a winner in every way!", + "Your attitude determines your altitude!", + "You're soaring to new heights!", + "Your potential knows no bounds!", + "You're a diamond in the rough!", + "Your light shines brightly!", + "You're a masterpiece in progress!", + "Your journey is inspiring!", + "You're a catalyst for change!", + "Your impact is significant!", + "You're leaving a positive mark!", + "Your legacy is being written!", + "You're a role model!", + "Your example inspires others!", + "You're making history!", + "Your story is worth telling!", + "You're a game changer!", + "Your influence is positive!", + "You're a trendsetter!", + "Your actions speak volumes!", + "You're a difference maker!", + "Your presence is a gift!" + ], + "morning": [ + "Good morning! Today is a new opportunity!", + "Rise and shine! You've got a great day ahead!", + "Morning! Time to make today amazing!", + "Start your day with a positive mindset!", + "Good morning! You're going to accomplish great things today!", + "Wake up and conquer the day!", + "Morning! Your potential is limitless!", + "Start strong! Today is yours to make great!", + "Good morning! Every sunrise is a chance to start fresh!", + "Morning! You're ready to tackle whatever comes your way!", + "Good morning! Today's possibilities are endless!", + "Rise and shine! Your best day starts now!", + "Morning! Embrace the new day with enthusiasm!", + "Good morning! You're starting fresh today!", + "Wake up! Today is full of potential!", + "Morning! Time to shine bright!", + "Good morning! You've got this day in the bag!", + "Rise up! Today is your day to excel!", + "Morning! Let's make today count!", + "Good morning! Your energy is contagious!", + "Wake up! Great things await you today!", + "Morning! Today is a blank canvas - paint it beautifully!", + "Good morning! You're starting with a clean slate!", + "Rise and shine! Today's adventure begins now!", + "Morning! You're equipped for whatever today brings!", + "Good morning! Today is your chance to shine!", + "Wake up! You're about to have an amazing day!", + "Morning! Today is full of opportunities!", + "Good morning! You're ready to make today great!", + "Rise and shine! Today is going to be wonderful!", + "Morning! Your positive energy starts now!", + "Good morning! Today is a gift - unwrap it with joy!", + "Wake up! You're going to crush it today!", + "Morning! Today is your time to thrive!", + "Good morning! You're starting the day right!", + "Rise and shine! Today's success begins with you!", + "Morning! You're about to have a productive day!", + "Good morning! Today is filled with possibilities!", + "Wake up! You're ready to take on the world!", + "Morning! Today is your day to excel!", + "Good morning! You're starting strong today!", + "Rise and shine! Today is going to be amazing!", + "Morning! You've got the power to make today great!", + "Good morning! Today is a fresh start!", + "Wake up! You're about to accomplish great things!", + "Morning! Today is your opportunity to shine!", + "Good morning! You're ready for whatever comes!", + "Rise and shine! Today is full of promise!", + "Morning! You're starting the day with purpose!", + "Good morning! Today is going to be fantastic!", + "Wake up! You're about to have a wonderful day!", + "Morning! Today is your chance to make a difference!", + "Good morning! You're starting with enthusiasm!", + "Rise and shine! Today is going to be productive!", + "Morning! You're ready to tackle today's challenges!", + "Good morning! Today is full of new beginnings!", + "Wake up! You're about to have an incredible day!", + "Morning! Today is your time to grow!", + "Good morning! You're starting the day with confidence!", + "Rise and shine! Today is going to be successful!", + "Morning! You've got everything you need for today!", + "Good morning! Today is a chance to be your best!", + "Wake up! You're about to have a positive day!", + "Morning! Today is full of potential victories!", + "Good morning! You're starting with a winning attitude!", + "Rise and shine! Today is going to be memorable!", + "Morning! You're ready to make today count!", + "Good morning! Today is your opportunity to progress!", + "Wake up! You're about to have a breakthrough day!", + "Morning! Today is full of learning opportunities!", + "Good morning! You're starting with determination!", + "Rise and shine! Today is going to be inspiring!", + "Morning! You've got the strength for today!", + "Good morning! Today is a chance to improve!", + "Wake up! You're about to have a fulfilling day!", + "Morning! Today is full of moments to cherish!", + "Good morning! You're starting with gratitude!", + "Rise and shine! Today is going to be joyful!", + "Morning! You're ready to embrace today's journey!", + "Good morning! Today is your chance to connect!", + "Wake up! You're about to have a meaningful day!", + "Morning! Today is full of reasons to smile!", + "Good morning! You're starting with hope!", + "Rise and shine! Today is going to be peaceful!", + "Morning! You've got the wisdom for today!", + "Good morning! Today is a chance to be kind!", + "Wake up! You're about to have a harmonious day!", + "Morning! Today is full of love and light!", + "Good morning! You're starting with compassion!", + "Rise and shine! Today is going to be beautiful!", + "Morning! You're ready to spread positivity today!", + "Good morning! Today is your chance to inspire!", + "Wake up! You're about to have a transformative day!", + "Morning! Today is full of growth opportunities!", + "Good morning! You're starting with an open heart!", + "Rise and shine! Today is going to be extraordinary!" + ], + "afternoon": [ + "Afternoon! Keep that momentum going!", + "You're halfway through the day - keep it up!", + "Afternoon! You're doing fantastic!", + "Keep pushing forward - you've got this!", + "Afternoon! Your energy is inspiring!", + "You're making great progress today!", + "Afternoon! Stay focused and keep going!", + "You're crushing it today!", + "Afternoon! Your determination is paying off!", + "Keep up the excellent work!", + "Afternoon! You're in the zone!", + "You're maintaining great momentum!", + "Afternoon! Your productivity is impressive!", + "You're handling the day beautifully!", + "Afternoon! Keep that positive energy flowing!", + "You're making steady progress!", + "Afternoon! Your focus is admirable!", + "You're staying strong and determined!", + "Afternoon! Keep up the great pace!", + "You're doing exceptionally well!", + "Afternoon! Your efforts are showing results!", + "You're maintaining your momentum!", + "Afternoon! Keep pushing through!", + "You're staying on track perfectly!", + "Afternoon! Your dedication is evident!", + "You're making smart decisions today!", + "Afternoon! Keep that winning attitude!", + "You're handling challenges like a pro!", + "Afternoon! Your resilience is showing!", + "You're adapting beautifully to the day!", + "Afternoon! Keep up the outstanding work!", + "You're maintaining excellent focus!", + "Afternoon! Your progress is steady!", + "You're staying motivated and strong!", + "Afternoon! Keep that positive momentum!", + "You're making the most of today!", + "Afternoon! Your energy is still going strong!", + "You're keeping up the great work!", + "Afternoon! Stay committed to your goals!", + "You're doing better than expected!", + "Afternoon! Your persistence is paying off!", + "You're maintaining your enthusiasm!", + "Afternoon! Keep that drive going!", + "You're staying productive and focused!", + "Afternoon! Your hard work is evident!", + "You're making significant progress!", + "Afternoon! Keep up the fantastic effort!", + "You're staying on top of everything!", + "Afternoon! Your determination is unwavering!", + "You're handling the afternoon like a champion!", + "Afternoon! Keep that positive spirit!", + "You're maintaining your excellent pace!", + "Afternoon! Your focus is laser-sharp!", + "You're staying strong and positive!", + "Afternoon! Keep pushing toward your goals!", + "You're making great strides today!", + "Afternoon! Your energy is still high!", + "You're keeping up the momentum!", + "Afternoon! Stay focused on the finish line!", + "You're doing an amazing job today!", + "Afternoon! Your progress is impressive!", + "You're maintaining your positive attitude!", + "Afternoon! Keep that determination strong!", + "You're staying productive and efficient!", + "Afternoon! Your efforts are making a difference!", + "You're handling everything beautifully!", + "Afternoon! Keep up the excellent momentum!", + "You're staying committed to success!", + "Afternoon! Your resilience is admirable!", + "You're making steady forward progress!", + "Afternoon! Keep that energy flowing!", + "You're staying on course perfectly!", + "Afternoon! Your dedication is inspiring!", + "You're maintaining your strong performance!", + "Afternoon! Keep pushing forward with confidence!", + "You're doing everything right today!", + "Afternoon! Your focus is unwavering!", + "You're staying motivated and energized!", + "Afternoon! Keep that positive momentum going!", + "You're making excellent progress today!", + "Afternoon! Your hard work is showing!", + "You're maintaining your excellent pace!", + "Afternoon! Stay strong and keep going!", + "You're handling the afternoon beautifully!", + "Afternoon! Your determination is strong!", + "You're staying productive and positive!", + "Afternoon! Keep up the outstanding effort!", + "You're making great progress today!", + "Afternoon! Your energy is still strong!", + "You're keeping up the excellent work!", + "Afternoon! Stay focused and determined!", + "You're doing fantastically well!", + "Afternoon! Your persistence is admirable!", + "You're maintaining your momentum perfectly!", + "Afternoon! Keep that drive and focus!", + "You're staying on track and thriving!", + "Afternoon! Your progress is remarkable!", + "You're handling everything with grace!", + "Afternoon! Keep up the fantastic momentum!", + "You're staying strong and positive!", + "Afternoon! Your dedication is paying off!", + "You're making the most of this afternoon!", + "Afternoon! Keep that positive energy high!", + "You're staying focused and productive!", + "Afternoon! Your efforts are truly appreciated!", + "You're maintaining excellence throughout the day!" + ], + "evening": [ + "Evening! You made it through another day!", + "Well done today! You should be proud!", + "Evening! Time to relax and reflect on your accomplishments!", + "You've earned some rest - great job today!", + "Evening! You handled today like a champion!", + "Take a moment to appreciate how far you've come!", + "Evening! You've done well today!", + "Rest well - you've earned it!", + "Evening! Tomorrow is another chance to shine!", + "You've made today count - well done!", + "Evening! You've accomplished so much today!", + "Time to unwind - you've earned this moment!", + "Evening! Reflect on all the good you did today!", + "You've been amazing today - take a break!", + "Evening! You've shown strength and resilience!", + "Rest peacefully - tomorrow brings new opportunities!", + "Evening! You've made progress today!", + "Take pride in what you accomplished!", + "Evening! You've been a positive force today!", + "You've earned this peaceful evening!", + "Evening! You've handled today with grace!", + "Time to recharge - you've done great!", + "Evening! You've been productive and focused!", + "Rest well - you've been working hard!", + "Evening! You've made a difference today!", + "Take a moment to appreciate yourself!", + "Evening! You've shown determination today!", + "You've earned this time to relax!", + "Evening! You've been inspiring today!", + "Rest peacefully - you've done well!", + "Evening! You've maintained your positive energy!", + "Time to unwind - you've been amazing!", + "Evening! You've stayed strong throughout the day!", + "You've earned this moment of peace!", + "Evening! You've been resilient today!", + "Take pride in your accomplishments!", + "Evening! You've shown great character today!", + "Rest well - tomorrow is a new day!", + "Evening! You've made today meaningful!", + "You've earned this time to reflect!", + "Evening! You've been a source of positivity!", + "Time to relax - you've done fantastic!", + "Evening! You've handled challenges well today!", + "You've earned this peaceful moment!", + "Evening! You've been focused and determined!", + "Rest peacefully - you've worked hard!", + "Evening! You've made progress on your goals!", + "Take a moment to celebrate your wins!", + "Evening! You've been productive today!", + "You've earned this time to recharge!", + "Evening! You've shown perseverance today!", + "Rest well - you've been inspiring!", + "Evening! You've maintained your momentum!", + "Time to unwind - you've done great!", + "Evening! You've been a positive influence!", + "You've earned this moment of tranquility!", + "Evening! You've stayed committed today!", + "Take pride in your hard work!", + "Evening! You've been resilient and strong!", + "Rest peacefully - you've accomplished much!", + "Evening! You've made today count!", + "You've earned this time to relax!", + "Evening! You've been focused and productive!", + "Time to reflect on your achievements!", + "Evening! You've shown great dedication!", + "You've earned this peaceful evening!", + "Evening! You've been a champion today!", + "Rest well - you've done exceptionally well!", + "Evening! You've maintained your positive attitude!", + "Take a moment to appreciate your progress!", + "Evening! You've been inspiring throughout the day!", + "You've earned this time to unwind!", + "Evening! You've handled everything beautifully!", + "Rest peacefully - you've been amazing!", + "Evening! You've made significant progress!", + "Time to celebrate your accomplishments!", + "Evening! You've been strong and determined!", + "You've earned this moment of rest!", + "Evening! You've shown excellent character!", + "Take pride in how you handled today!", + "Evening! You've been a positive force!", + "Rest well - you've worked with purpose!", + "Evening! You've made today meaningful!", + "You've earned this peaceful time!", + "Evening! You've been focused and committed!", + "Time to reflect on your success!", + "Evening! You've shown resilience today!", + "You've earned this moment to relax!", + "Evening! You've been productive and positive!", + "Rest peacefully - you've done well!", + "Evening! You've maintained your excellence!", + "Take a moment to appreciate yourself!", + "Evening! You've been inspiring all day!", + "You've earned this time to recharge!", + "Evening! You've handled today with strength!", + "Rest well - tomorrow brings new possibilities!", + "Evening! You've made today a success!", + "You've earned this peaceful evening!", + "Evening! You've been a beacon of positivity!", + "Time to unwind - you've accomplished much!", + "Evening! You've shown great determination!", + "You've earned this moment of tranquility!", + "Evening! You've been amazing today!", + "Rest peacefully - you've earned every moment of rest!" + ], + "....-01-01": [ + "Happy New Year! Here's to a year of growth and success!", + "New Year, new opportunities! You've got this!", + "Happy New Year! Make it your best year yet!", + "Welcome to a brand new year full of possibilities!", + "Happy New Year! Time to write a new chapter!", + "New Year, new beginnings! Your journey continues!", + "Happy New Year! May this year bring you joy!", + "Welcome to a year of endless opportunities!", + "Happy New Year! Time to set new goals and achieve them!", + "New Year, new dreams! Make them come true!", + "Happy New Year! Here's to reaching new heights!", + "Welcome to a year of growth and transformation!", + "Happy New Year! Time to shine brighter than ever!", + "New Year, new adventures await you!", + "Happy New Year! May this year exceed all expectations!", + "Welcome to a year of success and happiness!", + "Happy New Year! Time to make your dreams reality!", + "New Year, new possibilities! Embrace them all!", + "Happy New Year! Here's to a year of achievements!", + "Welcome to a year filled with blessings!", + "Happy New Year! Time to create amazing memories!", + "New Year, new goals! You're going to crush them!", + "Happy New Year! May this year bring prosperity!", + "Welcome to a year of positive changes!", + "Happy New Year! Time to unlock your full potential!", + "New Year, new milestones to reach!", + "Happy New Year! Here's to a year of breakthroughs!", + "Welcome to a year of learning and growing!", + "Happy New Year! Time to make every moment count!", + "New Year, new challenges to conquer!", + "Happy New Year! May this year be your best yet!", + "Welcome to a year of endless potential!", + "Happy New Year! Time to turn dreams into plans!", + "New Year, new victories to celebrate!", + "Happy New Year! Here's to a year of progress!", + "Welcome to a year of new experiences!", + "Happy New Year! Time to build something amazing!", + "New Year, new opportunities to seize!", + "Happy New Year! May this year bring fulfillment!", + "Welcome to a year of positive transformation!", + "Happy New Year! Time to reach for the stars!", + "New Year, new achievements to unlock!", + "Happy New Year! Here's to a year of excellence!", + "Welcome to a year of new beginnings!", + "Happy New Year! Time to make your mark!", + "New Year, new horizons to explore!", + "Happy New Year! May this year bring abundance!", + "Welcome to a year of success stories!", + "Happy New Year! Time to create your legacy!", + "New Year, new chapters to write!", + "Happy New Year! Here's to a year of innovation!", + "Welcome to a year of new discoveries!", + "Happy New Year! Time to push your boundaries!", + "New Year, new levels to reach!", + "Happy New Year! May this year bring clarity!", + "Welcome to a year of meaningful connections!", + "Happy New Year! Time to inspire and be inspired!", + "New Year, new wisdom to gain!", + "Happy New Year! Here's to a year of balance!", + "Welcome to a year of harmony and peace!", + "Happy New Year! Time to find your flow!", + "New Year, new rhythms to discover!", + "Happy New Year! May this year bring serenity!", + "Welcome to a year of inner growth!", + "Happy New Year! Time to nurture your soul!", + "New Year, new perspectives to gain!", + "Happy New Year! Here's to a year of understanding!", + "Welcome to a year of compassion and kindness!", + "Happy New Year! Time to spread love and joy!", + "New Year, new hearts to touch!", + "Happy New Year! May this year bring healing!", + "Welcome to a year of renewal and restoration!", + "Happy New Year! Time to bloom where you're planted!", + "New Year, new seeds to sow!", + "Happy New Year! Here's to a year of flourishing!", + "Welcome to a year of abundance in all things!", + "Happy New Year! Time to attract what you desire!", + "New Year, new manifestations to create!", + "Happy New Year! May this year bring miracles!", + "Welcome to a year of divine timing!", + "Happy New Year! Time to trust the journey!", + "New Year, new faith to strengthen!", + "Happy New Year! Here's to a year of hope!", + "Welcome to a year of optimism and positivity!", + "Happy New Year! Time to see the bright side!", + "New Year, new reasons to smile!", + "Happy New Year! May this year bring laughter!", + "Welcome to a year of joy and celebration!", + "Happy New Year! Time to dance through life!", + "New Year, new songs to sing!", + "Happy New Year! Here's to a year of music and melody!", + "Welcome to a year of creative expression!", + "Happy New Year! Time to paint your masterpiece!", + "New Year, new colors to explore!", + "Happy New Year! May this year be vibrant and alive!", + "Welcome to a year of passion and purpose!", + "Happy New Year! Time to live with intention!", + "New Year, new missions to accomplish!", + "Happy New Year! Here's to a year of impact!", + "Welcome to a year of making a difference!", + "Happy New Year! Time to leave your mark on the world!", + "New Year, new legacies to build!", + "Happy New Year! May this year be unforgettable!", + "Welcome to a year that will change everything!", + "Happy New Year! Time to embrace the journey ahead!" + ] +} diff --git a/css/main.css b/css/main.css index e025b4db65..d3a3d518bf 100644 --- a/css/main.css +++ b/css/main.css @@ -184,10 +184,16 @@ sup { top: 0; } +/* Reduce space at the top */ +.region.top.bar { + margin-top: -20px; /* Reduce top spacing */ +} + .region.top.center, .region.bottom.center { left: 50%; transform: translateX(-50%); + width: 40%; /* Center column takes 40% of width */ } .region.top.right, @@ -211,6 +217,12 @@ sup { text-align: center; } +/* Reduce spacing for top_bar modules */ +.region.top.bar .module, +.region.bottom.bar .module { + margin-bottom: 10px; /* Reduced from var(--gap-modules) which is 30px */ +} + .region.third, .region.middle.center { width: 100%; @@ -234,6 +246,26 @@ sup { text-align: left; } +/* Set widths for left, center, and right regions to prevent overlap */ +.region.bottom.left, +.region.top.left { + width: 30%; /* Left column takes 30% of width */ + left: 0; +} + +/* Add gutter on both sides of top_center */ +.region.top.center { + padding-left: 20px; /* Gutter on left side */ + padding-right: 20px; /* Gutter on right side */ + box-sizing: border-box; /* Include padding in width calculation */ +} + +.region.bottom.right, +.region.top.right { + width: 30%; /* Right column takes 30% of width */ + right: 0; +} + .region table { width: 100%; border-spacing: 0; @@ -264,3 +296,53 @@ sup { .region.right .flex { justify-content: flex-end; } + +/** + * Module content overflow handling. + */ + +.module-content { + /* Smooth scrolling */ + scrollbar-width: thin; + scrollbar-color: rgb(255 255 255 / 30%) transparent; +} + +/* Constrain module content in bottom regions to prevent overlap */ +.region.bottom .module-content { + max-height: 40vh; /* Limit content height to prevent overlap */ + overflow: hidden auto; /* Allow scrolling if content exceeds max-height */ +} + +.region.bottom.center .module-content { + max-height: 30vh; /* More constrained for center bottom (newsfeed) */ +} + +.region.bottom.left .module-content, +.region.bottom.right .module-content { + max-height: 40vh; /* Constrain left and right bottom modules */ +} + +/* Make calendar modules smaller to prevent overflow */ +.region.bottom.left .calendar .module-content, +.region.top.left .calendar .module-content { + max-height: 35vh; /* Constrain calendar height */ + overflow: hidden auto; +} + +/* For webkit browsers (Chrome, Safari, Edge) */ +.module-content::-webkit-scrollbar { + width: 4px; +} + +.module-content::-webkit-scrollbar-track { + background: transparent; +} + +.module-content::-webkit-scrollbar-thumb { + background-color: rgb(255 255 255 / 30%); + border-radius: 2px; +} + +.module-content::-webkit-scrollbar-thumb:hover { + background-color: rgb(255 255 255 / 50%); +} diff --git a/docs/ai-generated/ADMIN_PAGE_PLAN.md b/docs/ai-generated/ADMIN_PAGE_PLAN.md new file mode 100644 index 0000000000..08b7f752bc --- /dev/null +++ b/docs/ai-generated/ADMIN_PAGE_PLAN.md @@ -0,0 +1,421 @@ +# Admin Page Implementation Plan + +## Overview +This document outlines the implementation plan for creating an admin page that allows runtime configuration of MagicMirror modules, starting with the traffic module. + +## Goals +1. Create a `/admin` endpoint that serves an admin interface +2. Allow enabling/disabling the traffic module +3. Allow updating traffic module configuration: + - Destination + - Traffic model (BEST_GUESS, PESSIMISTIC, OPTIMISTIC) + - Update interval +4. Update traffic module to display "Traffic Module Disabled" when disabled +5. Create a new module to display the admin page URL (IP address and port) at the bottom of the screen + +## Architecture + +### Server-Side Components + +#### 1. Admin Endpoint (`/admin`) +- **Location**: `js/server.js` +- **Implementation**: Add Express route to serve admin HTML page +- **Method**: `app.get("/admin", ...)` +- **Response**: Serve static HTML file or generate HTML dynamically + +#### 2. Admin API Endpoints + +##### GET `/admin/api/config` +- **Purpose**: Retrieve current module configurations +- **Response**: JSON object containing module configs +- **Implementation**: Read from `config/config.js` and return relevant module data + +##### GET `/admin/api/config/traffic` +- **Purpose**: Get current traffic module configuration +- **Response**: JSON object with traffic module config +- **Example Response**: +```json +{ + "enabled": true, + "destination": "7079 S Kirkman Rd, Orlando, FL", + "trafficModel": "BEST_GUESS", + "updateInterval": 300000 +} +``` + +##### POST `/admin/api/config/traffic` +- **Purpose**: Update traffic module configuration +- **Request Body**: +```json +{ + "enabled": true, + "destination": "New Destination", + "trafficModel": "PESSIMISTIC", + "updateInterval": 600000 +} +``` +- **Implementation**: + 1. Read current `config/config.js` + 2. Parse and update traffic module config + 3. Write updated config back to file + 4. Broadcast socket notification to update module + 5. Return success/error response + +#### 3. Config File Management +- **Location**: `js/server_functions.js` or new `js/admin_functions.js` +- **Functions Needed**: + - `readConfig()` - Read and parse config.js + - `updateModuleConfig(moduleName, updates)` - Update specific module config + - `writeConfig(config)` - Write config back to file +- **Note**: Need to handle JavaScript file parsing carefully (not pure JSON) + +#### 4. Socket Notifications +- **Purpose**: Notify modules of configuration changes +- **Implementation**: Use existing Socket.IO instance to broadcast notifications +- **Notification Types**: + - `MODULE_CONFIG_UPDATED` - Notify module of config change + - `MODULE_DISABLED` - Notify module it's been disabled + - `MODULE_ENABLED` - Notify module it's been enabled + +#### 5. Admin Info Module Support +- **Purpose**: Provide server address information to the admininfo module +- **Implementation**: + - Add API endpoint `GET /admin/api/server-info` to return server address and port + - Use Node.js `os.networkInterfaces()` to get all network interfaces + - Filter to get non-internal IPv4 addresses (exclude 127.0.0.1, ::1, etc.) + - Return primary IP address, port, and protocol (http/https) + - Format: `{ address: "192.168.1.100", port: 8080, protocol: "http", url: "http://192.168.1.100:8080/admin" }` + +### Client-Side Components + +#### 1. Admin Page HTML +- **Location**: `admin.html` (new file) or serve from `js/server_functions.js` +- **Structure**: + - Header with title + - Section for Traffic Module configuration + - Form with: + - Enable/Disable toggle + - Destination input field + - Traffic Model dropdown (BEST_GUESS, PESSIMISTIC, OPTIMISTIC) + - Update Interval input (in minutes) + - Save button + - Status messages for success/error feedback + +#### 2. Admin Page JavaScript +- **Location**: Inline in HTML or separate `admin.js` file +- **Functionality**: + - Load current config on page load + - Handle form submission + - Make API calls to update config + - Display success/error messages + - Basic form validation + +#### 3. Admin Page Styling +- **Location**: Inline styles or separate `admin.css` +- **Design**: Simple, clean interface suitable for admin use + +### Traffic Module Updates + +#### 1. Handle Disabled State +- **Location**: `modules/default/traffic/traffic.js` +- **Changes**: + - Add `disabled` property to defaults (default: false) + - Check disabled state in `getTemplateData()` + - Return disabled message when disabled + - Stop updates when disabled + - Resume updates when enabled + +#### 2. Handle Config Updates +- **Location**: `modules/default/traffic/traffic.js` +- **Changes**: + - Listen for `MODULE_CONFIG_UPDATED` socket notification + - Update module config when notification received + - Restart update timer with new interval if changed + - Re-validate config (API key, origin, destination) + +#### 3. Update Template +- **Location**: `modules/default/traffic/traffic.njk` +- **Changes**: + - Add condition to display "Traffic Module Disabled" message + - Show this message when `disabled === true` + +### Admin Info Module (New Module) + +#### 1. Module Structure +- **Location**: `modules/default/admininfo/` (new directory) +- **Files**: + - `admininfo.js` - Main module file + - `admininfo.njk` - Template file + - `node_helper.js` - Node helper to fetch server info + - `admininfo.css` - Optional styling file + +#### 2. Module Implementation (`admininfo.js`) +- **Defaults**: + ```javascript + defaults: { + updateInterval: 60 * 1000, // Check every minute + showProtocol: true, // Show http:// or https:// + showPort: true, // Show port number + showPath: true, // Show /admin path + format: "full", // "full" (http://ip:port/admin) or "ip-only" or "url-only" + position: "bottom_bar" // Display at bottom + } + ``` +- **Functionality**: + - Request server info from node helper on start + - Display admin page URL in format: `http://192.168.1.100:8080/admin` + - Handle cases where IP address cannot be determined (show localhost) + - Update periodically to handle IP address changes + - Make URL clickable if possible (depends on MagicMirror display setup) + +#### 3. Node Helper (`node_helper.js`) +- **Purpose**: Fetch server information from the server +- **Implementation**: + - On start, request server info from `/admin/api/server-info` endpoint + - Parse response to get IP address, port, and protocol + - Send `ADMIN_INFO` socket notification to frontend module + - Re-fetch periodically based on `updateInterval` config + +#### 4. Template (`admininfo.njk`) +- **Display Format**: + - Full URL: `http://192.168.1.100:8080/admin` + - Or IP only: `192.168.1.100:8080` + - Or URL only: `/admin` (if IP not available) +- **Styling**: Small, dimmed text suitable for bottom bar +- **Structure**: + ```html +
+
Admin:
+
{{ adminUrl }}
+
+ ``` + +#### 5. Server Info API Endpoint +- **Location**: `js/server_functions.js` or `js/admin_functions.js` +- **Function**: `getServerInfo()` +- **Implementation**: + ```javascript + function getServerInfo(config) { + const os = require('os'); + const interfaces = os.networkInterfaces(); + let ipAddress = config.address || 'localhost'; + const port = process.env.MM_PORT || config.port || 8080; + const protocol = config.useHttps ? 'https' : 'http'; + + // Find first non-internal IPv4 address + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { + ipAddress = iface.address; + break; + } + } + if (ipAddress !== (config.address || 'localhost')) break; + } + + return { + address: ipAddress, + port: port, + protocol: protocol, + url: `${protocol}://${ipAddress}:${port}/admin` + }; + } + ``` +- **Endpoint**: `GET /admin/api/server-info` +- **Response**: + ```json + { + "success": true, + "serverInfo": { + "address": "192.168.1.100", + "port": 8080, + "protocol": "http", + "url": "http://192.168.1.100:8080/admin" + } + } + ``` + +## Implementation Steps + +### Phase 1: Server Infrastructure +1. Create admin API functions in `js/server_functions.js` or new `js/admin_functions.js` + - `readConfig()` - Parse config.js file + - `updateModuleConfig()` - Update module config in memory + - `writeConfig()` - Write config back to file + - `getTrafficConfig()` - Get traffic module config + - `updateTrafficConfig()` - Update traffic module config + - `getServerInfo()` - Get server IP address, port, and admin URL + +2. Add admin routes to `js/server.js` + - `GET /admin` - Serve admin page + - `GET /admin/api/config/traffic` - Get traffic config + - `POST /admin/api/config/traffic` - Update traffic config + - `GET /admin/api/server-info` - Get server address and admin URL + +3. Add socket notification broadcasting for config updates + +### Phase 2: Admin Page UI +1. Create admin page HTML with form +2. Add JavaScript for form handling and API calls +3. Add basic styling +4. Test form submission and API integration + +### Phase 3: Traffic Module Updates +1. Add disabled state handling to traffic module +2. Add socket notification listener for config updates +3. Update template to show disabled message +4. Test enable/disable functionality + +### Phase 4: Admin Info Module +1. Create `modules/default/admininfo/` directory +2. Implement `admininfo.js` module +3. Implement `node_helper.js` to fetch server info +4. Create `admininfo.njk` template +5. Add optional `admininfo.css` for styling +6. Test IP address detection and display +7. Test URL formatting options +8. Add module to config.js at bottom position + +### Phase 5: Integration Testing +1. Test full flow: update config via admin page → module updates +2. Test enable/disable functionality +3. Test all traffic model options +4. Test update interval changes +5. Verify config file persistence +6. Test admin info module displays correct URL +7. Verify admin info module updates when IP changes + +## Technical Considerations + +### Config File Parsing +- `config.js` is a JavaScript file, not JSON +- Need to parse it as JavaScript (using `require()` or `eval()`) +- Must preserve comments and formatting if possible +- Alternative: Use AST parser (like `esprima`) for safer parsing + +### Config File Writing +- Need to write valid JavaScript +- Preserve other modules' configs +- Handle edge cases (missing module, multiple instances) + +### Security +- Validate all input data +- Sanitize file paths to prevent directory traversal + +### Error Handling +- Handle file read/write errors gracefully +- Validate config before writing +- Provide user-friendly error messages +- Rollback on errors if possible + +### Module Instance Handling +- Handle case where traffic module appears multiple times in config +- Update all instances or allow selection of specific instance + +## File Structure + +``` +MagicMirror/ +├── js/ +│ ├── server.js (modified - add /admin routes) +│ ├── server_functions.js (modified - add admin functions) +│ └── admin_functions.js (new - admin-specific functions) +├── admin.html (new - admin page) +├── modules/default/ +│ ├── traffic/ +│ │ ├── traffic.js (modified - handle disabled state, config updates) +│ │ └── traffic.njk (modified - show disabled message) +│ └── admininfo/ (new - admin info display module) +│ ├── admininfo.js (new) +│ ├── admininfo.njk (new) +│ ├── node_helper.js (new) +│ └── admininfo.css (new - optional) +└── docs/ai-generated/ + └── ADMIN_PAGE_PLAN.md (this file) +``` + +## API Specification + +### GET /admin/api/server-info +**Purpose**: Get server IP address, port, and admin page URL + +**Response:** +```json +{ + "success": true, + "serverInfo": { + "address": "192.168.1.100", + "port": 8080, + "protocol": "http", + "url": "http://192.168.1.100:8080/admin" + } +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": "Unable to determine server address" +} +``` + +**Implementation Notes**: +- Uses `os.networkInterfaces()` to find network interfaces +- Prefers non-internal IPv4 addresses +- Falls back to config.address or 'localhost' if no external IP found +- Respects `config.useHttps` for protocol determination +- Uses `process.env.MM_PORT` or `config.port` for port + +### GET /admin/api/config/traffic +**Response:** +```json +{ + "success": true, + "config": { + "enabled": true, + "destination": "7079 S Kirkman Rd, Orlando, FL", + "trafficModel": "BEST_GUESS", + "updateInterval": 300000 + } +} +``` + +### POST /admin/api/config/traffic +**Request:** +```json +{ + "enabled": true, + "destination": "New Destination Address", + "trafficModel": "PESSIMISTIC", + "updateInterval": 600000 +} +``` + +**Response (Success):** +```json +{ + "success": true, + "message": "Traffic module configuration updated successfully" +} +``` + +**Response (Error):** +```json +{ + "success": false, + "error": "Error message here" +} +``` + +## Future Enhancements +- Support for configuring other modules +- Configuration history/undo +- Export/import configurations +- Real-time preview of changes +- Multiple module instance management +- Configuration templates/presets +- Admin info module: QR code generation for easy mobile access +- Admin info module: Copy-to-clipboard functionality +- Admin info module: Multiple IP address display (if multiple interfaces) + diff --git a/docs/ai-generated/TRAFFIC_MODULE_PLAN.md b/docs/ai-generated/TRAFFIC_MODULE_PLAN.md new file mode 100644 index 0000000000..f85f6ab9ff --- /dev/null +++ b/docs/ai-generated/TRAFFIC_MODULE_PLAN.md @@ -0,0 +1,469 @@ +# Traffic Congestion Module - Implementation Plan + +## Overview +This document outlines the plan to create a new MagicMirror module that displays traffic congestion information for a route using the Google Maps Routes API (the modern replacement for the legacy Directions API). + +## Module Name +`traffic` (to be placed in `modules/default/traffic/`) + +--- + +## 1. Architecture Overview + +### 1.1 Module Components +- **Frontend Module** (`traffic.js`): Handles UI rendering and user interaction +- **Node Helper** (`node_helper.js`): Handles API calls to Google Routes API (server-side to avoid CORS issues) +- **Template** (`traffic.njk`): Nunjucks template for rendering the traffic information +- **Stylesheet** (`traffic.css`): Custom styling for the traffic display +- **Translations**: Support for multiple languages (optional, can start with English) + +### 1.2 Data Flow +``` +User Config → Module Start → Node Helper → Google Routes API + ↓ + Parse Response + ↓ + Send to Frontend + ↓ + Display on Mirror +``` + +--- + +## 2. Google Maps API Integration + +### 2.1 Required API +- **Google Maps Routes API** (REST endpoint) + - Endpoint: `https://routes.googleapis.com/directions/v2:computeRoutes` + - **Note**: The Directions API is now legacy (as of March 1, 2025) and has been replaced by the Routes API + - The Routes API combines and enhances the functionalities of both the Directions API and Distance Matrix API + - Provides route information including: + - Duration in traffic + - Duration without traffic + - Distance + - Traffic congestion levels + - Route steps with traffic information + - Enhanced toll information + - Improved performance + - **Important**: The API returns traffic data **at the time of the request only**. It does NOT provide automatic updates or real-time streaming. You must implement a **polling mechanism** to periodically fetch updated traffic conditions (see section 6.2). + +### 2.2 API Key Requirements +- User must obtain a Google Cloud API key +- Enable "Routes API" in Google Cloud Console (not the legacy Directions API) +- Optionally restrict API key to specific IPs/domains for security + +### 2.3 API Request Format +- **Method**: POST (Routes API uses POST requests, unlike the legacy Directions API which used GET) +- **Request Body** (JSON): + - `origin`: Starting location (Waypoint object with address or lat/lng) + - `destination`: End location (Waypoint object with address or lat/lng) + - `routingPreference`: `TRAFFIC_AWARE` or `TRAFFIC_AWARE_OPTIMAL` + - `departureTime`: Current time in RFC3339 format (for traffic-aware routing) + - `trafficModel`: `BEST_GUESS`, `PESSIMISTIC`, or `OPTIMISTIC` + - `computeAlternativeRoutes`: `true` to get multiple route options + - `routeModifiers`: Object with `avoidTolls`, `avoidHighways`, `avoidFerries`, etc. + - `languageCode`: Language for directions + - `units`: `METRIC` or `IMPERIAL` + +### 2.4 API Response Structure +- `routes[]`: Array of route options + - `legs[]`: Route segments + - `duration`: Normal duration (Duration object) + - `staticDuration`: Duration without traffic + - `distanceMeters`: Route distance in meters + - `steps[]`: Detailed turn-by-turn directions + - `polyline`: Encoded polyline for route visualization + - `summary`: Route summary (e.g., "I-95 N") + - `warnings[]`: Route warnings + - `routeLabels[]`: Labels for route identification + +### 2.5 Key Differences from Legacy Directions API +- **Request Method**: Routes API uses POST instead of GET +- **Request Format**: JSON body instead of URL query parameters +- **Authentication**: API key passed in `X-Goog-Api-Key` header instead of query parameter +- **Response Format**: Slightly different structure (e.g., `distanceMeters` instead of `distance.value`) +- **Field Names**: Uses camelCase (e.g., `BEST_GUESS`) instead of snake_case +- **Enhanced Features**: Better toll information, improved traffic data, more routing options + +### 2.6 Migration Considerations +- The Routes API requires a POST request with JSON body, unlike the legacy Directions API which used GET with query parameters +- Response parsing will need to account for the new structure (e.g., Duration objects instead of simple values) +- Error handling should account for Routes API-specific error codes +- Google provides migration guides for transitioning from Directions API to Routes API + +--- + +## 3. Configuration Options + +### 3.1 Required Configuration +```javascript +{ + module: "traffic", + position: "top_right", // or any valid position + config: { + apiKey: "YOUR_GOOGLE_API_KEY", // Required + origin: "123 Main St, City, State", // Required - starting point + destination: "456 Oak Ave, City, State", // Required - destination + updateInterval: 5 * 60 * 1000, // Update every 5 minutes (default) + showAlternatives: false, // Show multiple route options + trafficModel: "best_guess", // best_guess, pessimistic, optimistic + avoid: [], // ["tolls", "highways", "ferries", "indoor"] + units: config.units, // metric or imperial + showRouteSummary: true, // Display route name (e.g., "I-95 N") + showDistance: true, // Display total distance + showDuration: true, // Display travel time + showTrafficDelay: true, // Show delay due to traffic + showTrafficLevel: true, // Show traffic level (light/moderate/heavy) + header: "Traffic", // Module header + animationSpeed: 1000, + fade: true, + fadePoint: 0.25 + } +} +``` + +### 3.2 Optional Advanced Configuration +- `originLat` / `originLng`: Use coordinates instead of address +- `destinationLat` / `destinationLng`: Use coordinates instead of address +- `departureTime`: Specific departure time (default: now) +- `language`: Language for directions (default: config.language) +- `region`: Region code for geocoding (e.g., "us") + +--- + +## 4. Module Structure + +### 4.1 File Structure +``` +modules/default/traffic/ +├── traffic.js # Main module file +├── node_helper.js # Server-side API handler +├── traffic.njk # Nunjucks template +├── traffic.css # Stylesheet +└── README.md # Module documentation +``` + +### 4.2 Frontend Module (`traffic.js`) + +**Key Methods:** +- `start()`: Initialize module, send config to node helper, trigger initial fetch +- `getDom()`: Generate DOM (or use template) +- `getTemplate()`: Return template filename +- `getTemplateData()`: Prepare data for template +- `socketNotificationReceived()`: Handle data from node helper +- `scheduleUpdate()`: Set up periodic polling to fetch updated traffic data +- `fetchTrafficData()`: Request new data from node helper (triggers API call) + +**Data Properties:** +- `trafficData`: Current traffic information +- `routes`: Array of route options +- `lastUpdate`: Timestamp of last update +- `error`: Error state if API call fails +- `updateTimer`: Reference to the polling timer + +**Polling Flow:** +1. Module starts → `start()` called +2. Send initial request to node helper → `sendSocketNotification("FETCH_TRAFFIC", config)` +3. Schedule next update → `scheduleUpdate()` +4. When data received → `socketNotificationReceived("TRAFFIC_DATA", data)` +5. After processing → `scheduleUpdate()` schedules next poll +6. Repeat steps 2-5 at configured interval + +### 4.3 Node Helper (`node_helper.js`) + +**Key Methods:** +- `start()`: Initialize helper +- `socketNotificationReceived()`: Handle `FETCH_TRAFFIC` notification from frontend, triggers API call +- `fetchTrafficData()`: Make API call to Google Routes API (POST request) +- `processTrafficData()`: Parse and format API response +- `sendTrafficData()`: Send processed data to frontend via `sendSocketNotification("TRAFFIC_DATA", data)` + +**Dependencies:** +- `node-fetch` or native `https` module for API calls +- JSON handling for request/response (native `JSON`) + +**Note**: The node helper does NOT implement polling itself. It responds to requests from the frontend module, which handles the polling schedule. This separation allows the frontend to control update timing and pause updates when the module is hidden. + +### 4.4 Template (`traffic.njk`) + +**Display Elements:** +- Route summary (e.g., "I-95 N") +- Origin → Destination +- Current travel time +- Normal travel time (without traffic) +- Traffic delay (difference) +- Distance +- Traffic level indicator (color-coded) +- Alternative routes (if enabled) + +**Layout Options:** +- Compact view (single line) +- Detailed view (multi-line with more info) +- Icon-based indicators for traffic levels + +### 4.5 Stylesheet (`traffic.css`) + +**Styling Considerations:** +- Traffic level colors: + - Green: Light traffic + - Yellow: Moderate traffic + - Orange: Heavy traffic + - Red: Severe traffic +- Responsive layout +- Fade animations +- Icon styling (Font Awesome) +- Typography matching MagicMirror theme + +--- + +## 5. Implementation Steps + +### Phase 1: Basic Setup +1. ✅ Create module directory structure +2. ✅ Create basic `traffic.js` with module registration +3. ✅ Create basic `node_helper.js` with socket communication +4. ✅ Create minimal `traffic.njk` template +5. ✅ Create basic `traffic.css` stylesheet +6. ✅ Add module to `modules/default/defaultmodules.js` + +### Phase 2: API Integration +1. ✅ Implement Google Routes API call in node helper (POST request) +2. ✅ Handle API authentication (API key in header) +3. ✅ Parse API response (note: response structure differs from Directions API) +4. ✅ Calculate traffic metrics (delay, level) +5. ✅ Error handling for API failures + +### Phase 3: Frontend Display +1. ✅ Implement template rendering +2. ✅ Display route information +3. ✅ Show travel times and delays +4. ✅ Traffic level indicators +5. ✅ Update mechanism (periodic refresh) + +### Phase 4: Configuration +1. ✅ Implement all configuration options +2. ✅ Support address and coordinate inputs +3. ✅ Route alternatives support +4. ✅ Customizable display options + +### Phase 5: Polish & Testing +1. ✅ Error handling and user feedback +2. ✅ Loading states +3. ✅ Responsive design +4. ✅ Translation support (optional) +5. ✅ Documentation +6. ✅ Testing with various routes and scenarios + +--- + +## 6. Technical Details + +### 6.1 Polling Mechanism (Required) + +**Critical Understanding**: The Google Routes API is a **request-response API**, not a real-time streaming service. Each API call returns traffic conditions at that moment, but the API does NOT push updates automatically. + +**Why Polling is Required:** +- Traffic conditions change continuously throughout the day +- The API only provides data when you request it +- Without polling, traffic information becomes stale quickly +- MagicMirror modules typically use polling for external data (weather, news, etc.) + +**How Polling Works in This Module:** + +1. **Frontend Module** (`traffic.js`) manages the polling schedule: + ```javascript + start() { + // Initial fetch + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + // Schedule periodic updates + this.scheduleUpdate(this.config.initialLoadDelay); + } + + scheduleUpdate(delay = null) { + let nextLoad = this.config.updateInterval || 5 * 60 * 1000; // 5 min default + if (delay !== null && delay >= 0) { + nextLoad = delay; + } + + setTimeout(() => { + // Request fresh data from node helper + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + // Schedule next update (recursive) + this.scheduleUpdate(); + }, nextLoad); + } + ``` + +2. **Node Helper** (`node_helper.js`) handles API calls on demand: + ```javascript + socketNotificationReceived(notification, payload) { + if (notification === "FETCH_TRAFFIC") { + this.fetchTrafficData(payload); + } + } + + async fetchTrafficData(config) { + // Make API call to Google Routes API + // Process response + // Send back to frontend + this.sendSocketNotification("TRAFFIC_DATA", processedData); + } + ``` + +3. **Update Flow:** + ``` + Frontend → sendSocketNotification("FETCH_TRAFFIC") + → Node Helper receives notification + → Node Helper calls Google Routes API + → Node Helper processes response + → Node Helper → sendSocketNotification("TRAFFIC_DATA") + → Frontend receives data and updates display + → Frontend schedules next poll + → Repeat at configured interval + ``` + +**Polling Best Practices:** +- **Default interval**: 5 minutes (good balance of freshness vs. API costs) +- **Rush hour**: Consider 2-3 minutes for more frequent updates +- **Off-peak**: 10-15 minutes may be sufficient +- **Error handling**: On API failure, retry with exponential backoff +- **Pause when hidden**: Consider pausing updates when module is hidden (future enhancement) +- **Respect rate limits**: Google API has quotas; don't poll too aggressively + +**Comparison with Other Modules:** +- Weather module: Polls every 10 minutes by default +- Newsfeed module: Polls every 5 minutes by default +- Calendar module: Polls based on configured interval +- Traffic module: Should follow similar pattern (5-10 minute default) + +### 6.2 Traffic Level Calculation +Based on `duration_in_traffic` vs `duration`: +- **Light**: Delay < 10% of normal duration +- **Moderate**: Delay 10-30% of normal duration +- **Heavy**: Delay 30-50% of normal duration +- **Severe**: Delay > 50% of normal duration + +### 6.3 Update Strategy +- **Initial load**: Fetch immediately on module start +- **Periodic updates**: Handled by polling mechanism (see section 6.1) +- **Update interval**: Configurable via `updateInterval` config (default: 5 minutes) +- **Manual refresh**: Could be triggered by notification (future enhancement) + +### 6.3 Error Handling +- Invalid API key → Show error message +- Network failure → Retry with exponential backoff +- Invalid addresses → Show geocoding error +- No route found → Show "No route available" +- Rate limiting → Respect API quotas + +### 6.4 Caching Considerations +- Cache route data briefly to avoid excessive API calls +- Consider caching during same-minute requests +- Clear cache on config changes + +--- + +## 7. API Cost Considerations + +### 7.1 Google Routes API Pricing +- Pay-per-use pricing model +- Free tier: $200/month credit (typically ~40,000 requests) +- Standard pricing: ~$5 per 1,000 requests (similar to legacy Directions API) +- Note: Routes API combines Directions and Distance Matrix functionality + +### 7.2 Optimization Strategies +- Reasonable default update interval (5-10 minutes) +- Cache responses for short periods +- Only fetch when module is visible +- User education about API costs + +--- + +## 8. Future Enhancements (Post-MVP) + +1. **Multiple Routes**: Support multiple origin/destination pairs +2. **Route Comparison**: Side-by-side comparison of alternatives +3. **Historical Data**: Show typical vs current traffic +4. **Notifications**: Alert when traffic exceeds threshold +5. **Map Visualization**: Optional mini-map display +6. **Waypoints**: Support multi-stop routes +7. **Saved Routes**: Store favorite routes in config +8. **Traffic Trends**: Show traffic patterns over time + +--- + +## 9. Testing Checklist + +- [ ] Module loads without errors +- [ ] API key validation works +- [ ] Route calculation successful +- [ ] Traffic data displays correctly +- [ ] Update interval works +- [ ] Error handling for invalid addresses +- [ ] Error handling for network failures +- [ ] Multiple route alternatives display +- [ ] Different traffic levels display correctly +- [ ] Configuration options work +- [ ] Module hides/shows correctly +- [ ] Responsive design on different screen sizes + +--- + +## 10. Documentation Requirements + +1. **README.md**: Module overview, installation, configuration +2. **API Setup Guide**: How to obtain Google API key +3. **Configuration Examples**: Sample configs for different use cases +4. **Troubleshooting**: Common issues and solutions +5. **Contributing**: Guidelines for contributors + +--- + +## 11. Dependencies + +### 11.1 Node.js Dependencies +- None required (use native `https` module) +- Optional: `node-fetch` for cleaner HTTP requests + +### 11.2 Frontend Dependencies +- None required (use MagicMirror's built-in utilities) +- Font Awesome (already included in MagicMirror) + +--- + +## 12. Security Considerations + +1. **API Key Protection**: + - Store API key in config (not in code) + - Warn users about API key security + - Consider server-side proxy for API key (advanced) + +2. **Input Validation**: + - Validate addresses/coordinates + - Sanitize user inputs + - Prevent injection attacks + +3. **Rate Limiting**: + - Respect API quotas + - Implement request throttling + - Handle rate limit errors gracefully + +--- + +## 13. Accessibility + +- Semantic HTML structure +- Color-blind friendly traffic indicators (use icons + colors) +- Screen reader support +- Keyboard navigation (if interactive elements added) + +--- + +## Summary + +This plan provides a comprehensive roadmap for implementing a traffic congestion module for MagicMirror. The module will use the Google Maps Routes API (the modern replacement for the legacy Directions API) to fetch real-time traffic information and display it in a user-friendly format on the MagicMirror interface. + +The implementation follows MagicMirror's standard module architecture with a frontend module and node helper for server-side API calls, ensuring proper separation of concerns and avoiding CORS issues. + +**Important Note**: The Google Directions API was designated as legacy on March 1, 2025. This plan uses the Routes API, which is the recommended replacement and offers improved performance and additional features. + diff --git a/docs/ai-generated/todo.md b/docs/ai-generated/todo.md new file mode 100644 index 0000000000..c81d3eeeea --- /dev/null +++ b/docs/ai-generated/todo.md @@ -0,0 +1,7 @@ +# TODO + +## Tasks + +- [x] Add traffic module to application +- [x] Update compliments configuration to have custom compliments remote file +- [x] Create admin page for module configuration management diff --git a/js/server.js b/js/server.js index fb17b9068d..ccef5fb821 100644 --- a/js/server.js +++ b/js/server.js @@ -7,7 +7,7 @@ const ipfilter = require("express-ipfilter").IpFilter; const helmet = require("helmet"); const socketio = require("socket.io"); const Log = require("logger"); -const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions"); +const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, readConfig, getServerInfo, getTrafficConfig } = require("#server_functions"); const vendor = require(`${__dirname}/vendor`); @@ -21,6 +21,8 @@ function Server (config) { const port = process.env.MM_PORT || config.port; const serverSockets = new Set(); let server = null; + let io = null; + let currentModuleConfigs = {}; /** * Opens the server for incoming connections @@ -37,7 +39,7 @@ function Server (config) { } else { server = http.Server(app); } - const io = socketio(server, { + io = socketio(server, { cors: { origin: /.*$/, credentials: true @@ -80,6 +82,86 @@ function Server (config) { server.listen(port, config.address || "localhost"); + // Admin routes - placed before ipWhitelist to allow unrestricted access + app.use(express.json()); + app.get("/admin", (req, res) => { + const adminHtml = fs.readFileSync(path.resolve(`${global.root_path}/admin.html`), { encoding: "utf8" }); + res.send(adminHtml); + }); + + app.get("/admin/api/server-info", (req, res) => { + try { + const serverInfo = getServerInfo(config); + res.json({ success: true, serverInfo }); + } catch (error) { + Log.error("[Admin] Error getting server info:", error); + res.json({ success: false, error: "Unable to determine server address" }); + } + }); + + app.get("/admin/api/config/traffic", (req, res) => { + try { + const storedConfig = readConfig(); + const currentTrafficConfig = currentModuleConfigs.traffic; + const storedTrafficConfig = getTrafficConfig(storedConfig); + if (storedTrafficConfig === null && currentTrafficConfig === null) { + res.json({ success: false, error: "Traffic module not found in config" }); + } else { + res.json({ success: true, config: currentTrafficConfig || storedTrafficConfig }); + } + } catch (error) { + Log.error("[Admin] Error getting traffic config:", error); + res.json({ success: false, error: error.message || "Failed to get traffic configuration" }); + } + }); + + app.post("/admin/api/config/traffic", (req, res) => { + try { + // Broadcast socket notification to update modules + if (io) { + // Send scheduler config update to node_helper + if (req.body.schedulerEnabled !== undefined) { + // Calculate disableTime if not set (enableTime + 90 minutes) + let disableTime = req.body.disableTime; + if (!disableTime && req.body.enableTime) { + const enableParts = req.body.enableTime.split(":"); + if (enableParts.length === 2) { + const hours = parseInt(enableParts[0], 10); + const minutes = parseInt(enableParts[1], 10); + if (!isNaN(hours) && !isNaN(minutes)) { + let totalMinutes = hours * 60 + minutes + 90; + if (totalMinutes >= 24 * 60) { + totalMinutes = totalMinutes % (24 * 60); + } + const disableHours = Math.floor(totalMinutes / 60); + const disableMinutes = totalMinutes % 60; + disableTime = `${String(disableHours).padStart(2, "0")}:${String(disableMinutes).padStart(2, "0")}`; + } + } + } + + io.of("traffic").emit("SCHEDULER_CONFIG_UPDATED", { + schedulerEnabled: req.body.schedulerEnabled, + schedulerMode: req.body.schedulerMode || "timeRange", + enableTime: req.body.enableTime || "08:00", + disableTime: disableTime || "09:30", + enabledDuration: req.body.enabledDuration || 90 + }); + } + + io.of("traffic").emit("MODULE_CONFIG_UPDATED", req.body); + if (req.body.enabled !== undefined) { + currentModuleConfigs.traffic = req.body; + io.of("traffic").emit(req.body.enabled ? "MODULE_ENABLED" : "MODULE_DISABLED", {}); + } + } + res.json({ success: true, message: "Traffic module configuration updated successfully" }); + } catch (error) { + Log.error("[Admin] Error updating traffic config:", error); + res.json({ success: false, error: error.message || "Failed to update traffic configuration" }); + } + }); + if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) { Log.warn("You're using a full whitelist configuration to allow for all IPs"); } diff --git a/js/server_functions.js b/js/server_functions.js index 1f206ccd88..d3ba1a68ec 100644 --- a/js/server_functions.js +++ b/js/server_functions.js @@ -1,5 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); +const os = require("node:os"); const Log = require("logger"); const startUp = new Date(); @@ -176,4 +177,99 @@ function getEnvVars (req, res) { res.send(obj); } -module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent }; +/** + * Gets the config file path + * @returns {string} Path to config file + */ +function getConfigFilePath () { + return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`); +} + +/** + * Reads and parses the config file + * @returns {object} The config object + */ +function readConfig () { + try { + const configPath = getConfigFilePath(); + // Clear require cache to get fresh config + delete require.cache[require.resolve(configPath)]; + const config = require(configPath); + return config; + } catch (error) { + Log.error("[Admin] Error reading config:", error); + throw error; + } +} + +/** + * Gets server information (IP address, port, protocol) + * @param {object} config The MM config + * @returns {object} Server info object + */ +function getServerInfo (config) { + const interfaces = os.networkInterfaces(); + let ipAddress = config.address || "localhost"; + const port = process.env.MM_PORT || config.port || 8080; + const protocol = config.useHttps ? "https" : "http"; + + // Find first non-internal IPv4 address + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal) { + ipAddress = iface.address; + break; + } + } + if (ipAddress !== (config.address || "localhost")) break; + } + + // If still localhost and address is 0.0.0.0, try to find any non-loopback IP + if (ipAddress === "localhost" && (config.address === "0.0.0.0" || config.address === "::")) { + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name]) { + if (iface.family === "IPv4" && !iface.internal && iface.address !== "127.0.0.1") { + ipAddress = iface.address; + break; + } + } + if (ipAddress !== "localhost") break; + } + } + + return { + address: ipAddress, + port: port, + protocol: protocol, + url: `${protocol}://${ipAddress}:${port}/admin` + }; +} + +/** + * Gets traffic module configuration + * @param {object} config The MM config + * @returns {object} Traffic module config + */ +function getTrafficConfig (config) { + // Find traffic module in config + const trafficModule = config.modules.find((m) => m.module === "traffic"); + if (!trafficModule) { + return null; + } + + const moduleConfig = trafficModule.config || {}; + return { + enabled: !trafficModule.disabled, + destination: moduleConfig.destination || "", + trafficModel: moduleConfig.trafficModel || "BEST_GUESS", + updateInterval: moduleConfig.updateInterval || 5 * 60 * 1000, + avoid: moduleConfig.avoid || [], + schedulerEnabled: moduleConfig.schedulerEnabled || false, + schedulerMode: moduleConfig.schedulerMode || "timeRange", + enableTime: moduleConfig.enableTime || "08:00", + disableTime: moduleConfig.disableTime || null, // Will be calculated if not set + enabledDuration: moduleConfig.enabledDuration || 90 + }; +} + +module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, readConfig, getServerInfo, getTrafficConfig, getConfigFilePath }; diff --git a/modules/default/admininfo/admininfo.css b/modules/default/admininfo/admininfo.css new file mode 100644 index 0000000000..1b2123eba6 --- /dev/null +++ b/modules/default/admininfo/admininfo.css @@ -0,0 +1,16 @@ +.admininfo-container { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.75rem; + color: rgb(255 255 255 / 60%); +} + +.admininfo-label { + font-weight: 500; +} + +.admininfo-url { + font-family: monospace; + color: rgb(255 255 255 / 80%); +} diff --git a/modules/default/admininfo/admininfo.js b/modules/default/admininfo/admininfo.js new file mode 100644 index 0000000000..c77f49ad4f --- /dev/null +++ b/modules/default/admininfo/admininfo.js @@ -0,0 +1,120 @@ +Module.register("admininfo", { + // Default module config. + defaults: { + updateInterval: 60 * 1000, // Check every minute + showProtocol: true, // Show http:// or https:// + showPort: true, // Show port number + showPath: true, // Show /admin path + format: "full" // "full" (http://ip:port/admin) or "ip-only" or "url-only" + }, + + // Module properties. + adminUrl: null, + serverInfo: null, + updateTimer: null, + loaded: false, + + // Define required styles. + getStyles () { + return ["admininfo.css"]; + }, + + // Define start sequence. + start () { + Log.info(`Starting module: ${this.name}`); + this.loaded = false; + this.sendSocketNotification("GET_ADMIN_INFO"); + }, + + // Override socket notification handler. + socketNotificationReceived (notification, payload) { + if (notification === "ADMIN_INFO") { + this.serverInfo = payload; + this.formatAdminUrl(); + this.loaded = true; + this.updateDom(); + } + }, + + /** + * Format the admin URL based on config + */ + formatAdminUrl () { + if (!this.serverInfo || !this.serverInfo.success) { + this.adminUrl = "Unable to determine admin URL"; + return; + } + + const info = this.serverInfo.serverInfo; + + if (this.config.format === "ip-only") { + this.adminUrl = `${info.address}:${info.port}`; + } else if (this.config.format === "url-only") { + this.adminUrl = "/admin"; + } else { + // full format + let url = ""; + if (this.config.showProtocol) { + url += `${info.protocol}://`; + } + url += info.address; + if (this.config.showPort) { + url += `:${info.port}`; + } + if (this.config.showPath) { + url += "/admin"; + } + this.adminUrl = url; + } + }, + + /** + * Schedule the next update + */ + scheduleUpdate () { + // Clear existing timer + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + + this.updateTimer = setTimeout(() => { + this.sendSocketNotification("GET_ADMIN_INFO"); + this.scheduleUpdate(); + }, this.config.updateInterval); + }, + + // Override getTemplate method. + getTemplate () { + return "admininfo.njk"; + }, + + // Override getTemplateData method. + getTemplateData () { + return { + adminUrl: this.adminUrl || "Loading...", + loaded: this.loaded + }; + }, + + /** + * Called when module is about to be hidden + */ + suspend () { + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + }, + + /** + * Called when module is about to be shown + */ + resume () { + // Resume updates if needed + if (!this.updateTimer && this.loaded) { + this.scheduleUpdate(); + } + } +}); + diff --git a/modules/default/admininfo/admininfo.njk b/modules/default/admininfo/admininfo.njk new file mode 100644 index 0000000000..6f662f415e --- /dev/null +++ b/modules/default/admininfo/admininfo.njk @@ -0,0 +1,4 @@ +
+
Admin:
+
{{ adminUrl }}
+
diff --git a/modules/default/admininfo/node_helper.js b/modules/default/admininfo/node_helper.js new file mode 100644 index 0000000000..ef04c1fd96 --- /dev/null +++ b/modules/default/admininfo/node_helper.js @@ -0,0 +1,38 @@ +const NodeHelper = require("node_helper"); +const Log = require("logger"); +const { readConfig, getServerInfo } = require("#server_functions"); + +module.exports = NodeHelper.create({ + // Override start method. + start () { + Log.log(`Starting node helper for: ${this.name}`); + }, + + // Override socketNotificationReceived. + socketNotificationReceived (notification, payload) { + if (notification === "GET_ADMIN_INFO") { + this.fetchAdminInfo(); + } + }, + + /** + * Fetch admin server information + */ + fetchAdminInfo () { + try { + const config = readConfig(); + const serverInfo = getServerInfo(config); + this.sendSocketNotification("ADMIN_INFO", { + success: true, + serverInfo: serverInfo + }); + } catch (error) { + Log.error(`[AdminInfo] Error fetching admin info: ${error.message}`); + this.sendSocketNotification("ADMIN_INFO", { + success: false, + error: error.message || "Failed to get server info" + }); + } + } +}); + diff --git a/modules/default/calendar/calendar.css b/modules/default/calendar/calendar.css index 973a5abb84..2866ec832e 100644 --- a/modules/default/calendar/calendar.css +++ b/modules/default/calendar/calendar.css @@ -1,3 +1,7 @@ +.calendar { + max-height: inherit; /* Respect parent max-height */ +} + .calendar .symbol { display: flex; flex-direction: row; @@ -7,9 +11,17 @@ .calendar .title { padding: 0 10px; + overflow-wrap: break-word; /* Prevent long titles from breaking layout */ + overflow-wrap: break-word; } .calendar .time { padding-left: 20px; text-align: right; } + +.calendar .event { + /* Ensure events don't cause overflow */ + overflow-wrap: break-word; + overflow-wrap: break-word; +} diff --git a/modules/default/defaultmodules.js b/modules/default/defaultmodules.js index 695bba4476..fb403c637c 100644 --- a/modules/default/defaultmodules.js +++ b/modules/default/defaultmodules.js @@ -2,7 +2,7 @@ * Default Modules List * Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name. */ -const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather"]; +const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "traffic", "updatenotification", "weather", "admininfo", "MMM-Wallpaper"]; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { diff --git a/modules/default/newsfeed/newsfeed.css b/modules/default/newsfeed/newsfeed.css index 2c690a48e2..10a8f57df9 100644 --- a/modules/default/newsfeed/newsfeed.css +++ b/modules/default/newsfeed/newsfeed.css @@ -16,9 +16,15 @@ iframe.newsfeed-fullarticle { .newsfeed-list { list-style: none; + max-height: inherit; /* Respect parent max-height */ } .newsfeed-list li { text-align: justify; margin-bottom: 0.5em; } + +.newsfeed-title { + overflow-wrap: break-word; /* Prevent long titles from breaking layout */ + overflow-wrap: break-word; +} diff --git a/modules/default/traffic/README.md b/modules/default/traffic/README.md new file mode 100644 index 0000000000..81dea36868 --- /dev/null +++ b/modules/default/traffic/README.md @@ -0,0 +1,121 @@ +# Traffic Module + +The Traffic module displays real-time traffic congestion information for a route using the Google Maps Routes API. + +## Features + +- Real-time traffic conditions +- Travel time with and without traffic +- Traffic delay calculation +- Traffic level indicators (Light/Moderate/Heavy/Severe) +- Route distance +- Support for alternative routes +- Automatic periodic updates + +## Installation + +This module is included in the default MagicMirror modules. No additional installation is required. + +## Configuration + +Add the following configuration block to your `config/config.js` file: + +```javascript +{ + module: "traffic", + position: "top_right", // any valid position + header: "Traffic", + config: { + apiKey: "YOUR_GOOGLE_ROUTES_API_KEY", + origin: "123 Main St, City, State", + destination: "456 Oak Ave, City, State", + updateInterval: 5 * 60 * 1000, // 5 minutes + } +} +``` + +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `apiKey` | String | **Required** | Your Google Routes API key | +| `origin` | String | **Required** | Starting address or coordinates | +| `destination` | String | **Required** | Destination address or coordinates | +| `updateInterval` | Number | `5 * 60 * 1000` | Update interval in milliseconds (5 minutes) | +| `initialLoadDelay` | Number | `0` | Initial load delay in milliseconds | +| `showAlternatives` | Boolean | `false` | Show alternative routes | +| `trafficModel` | String | `"BEST_GUESS"` | Traffic model: `"BEST_GUESS"`, `"PESSIMISTIC"`, or `"OPTIMISTIC"` | +| `routingPreference` | String | `"TRAFFIC_AWARE_OPTIMAL"` | `"TRAFFIC_AWARE"` or `"TRAFFIC_AWARE_OPTIMAL"` | +| `avoid` | Array | `[]` | Avoid options: `["TOLLS", "HIGHWAYS", "FERRIES", "INDOOR"]` | +| `units` | String | `config.units` | `"metric"` or `"imperial"` | +| `showDistance` | Boolean | `true` | Display total distance | +| `showDuration` | Boolean | `true` | Display travel time | +| `showTrafficDelay` | Boolean | `true` | Show delay due to traffic | +| `showTrafficLevel` | Boolean | `true` | Show traffic level indicator | +| `header` | String | `"Traffic"` | Module header text | +| `animationSpeed` | Number | `1000` | Animation speed in milliseconds | +| `fade` | Boolean | `true` | Enable fade animations | +| `fadePoint` | Number | `0.25` | Fade point (0-1) | + +### Using Coordinates + +Instead of addresses, you can use coordinates: + +```javascript +config: { + apiKey: "YOUR_API_KEY", + originLat: 28.5383, + originLng: -81.3792, + destinationLat: 28.4158, + destinationLng: -81.2989, + // ... other options +} +``` + +## Google Routes API Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the **Routes API** (not the legacy Directions API) +4. Create credentials → API Key +5. (Recommended) Restrict the API key to Routes API only +6. Copy your API key and add it to the module configuration + +**Important**: The Google Directions API was designated as legacy on March 1, 2025. This module uses the modern Routes API. + +## API Costs + +- Free tier: $200/month credit (typically ~40,000 requests) +- Standard pricing: ~$5 per 1,000 requests +- Default update interval (5 minutes) = ~288 requests/day = ~8,640 requests/month + +## Traffic Levels + +Traffic levels are calculated based on delay percentage: +- **Light**: Delay < 10% of normal duration +- **Moderate**: Delay 10-30% of normal duration +- **Heavy**: Delay 30-50% of normal duration +- **Severe**: Delay > 50% of normal duration + +## Troubleshooting + +### Module shows "Loading traffic data..." indefinitely +- Check that your API key is correct +- Verify the Routes API is enabled in Google Cloud Console +- Check the browser console and server logs for errors + +### "API key is required" error +- Make sure `apiKey` is set in your config + +### "Origin and destination are required" error +- Ensure both `origin` and `destination` are configured +- Or use `originLat`/`originLng` and `destinationLat`/`destinationLng` + +### No route found +- Verify the addresses are valid and accessible by car +- Check that the route exists (not blocked, etc.) + +## License + +This module is part of MagicMirror² and follows the same license. + diff --git a/modules/default/traffic/node_helper.js b/modules/default/traffic/node_helper.js new file mode 100644 index 0000000000..ac2e3a5b72 --- /dev/null +++ b/modules/default/traffic/node_helper.js @@ -0,0 +1,347 @@ +const https = require("https"); +const NodeHelper = require("node_helper"); +const Log = require("logger"); + +module.exports = NodeHelper.create({ + // Override start method. + start () { + Log.log(`Starting node helper for: ${this.name}`); + this.schedulerTimer = null; + this.schedulerConfig = null; + this.lastEnabledState = null; + this.enabledAtTime = null; // Track when module was enabled (for duration mode) + }, + + stop () { + // Clean up scheduler timer + if (this.schedulerTimer) { + clearInterval(this.schedulerTimer); + this.schedulerTimer = null; + } + }, + + // Override socketNotificationReceived. + socketNotificationReceived (notification, payload) { + if (notification === "FETCH_TRAFFIC") { + this.fetchTrafficData(payload); + } else if (notification === "SCHEDULER_CONFIG_UPDATED") { + this.updateScheduler(payload); + } + }, + + /** + * Fetch traffic data from Google Routes API + * @param {object} config Module configuration + */ + async fetchTrafficData (config) { + try { + Log.info(`[Traffic] Fetching traffic data for route: ${config.origin} → ${config.destination}`); + + // Build request body + const requestBody = this.buildRequestBody(config); + + // Make API request + const response = await this.makeApiRequest(config.apiKey, requestBody); + + // Process and send response + const processedData = this.processApiResponse(response); + this.sendSocketNotification("TRAFFIC_DATA", processedData); + + Log.info("[Traffic] Successfully fetched traffic data"); + } catch (error) { + Log.error("[Traffic] Error fetching traffic data:", error); + this.sendSocketNotification("TRAFFIC_ERROR", { + error: error.message || "Failed to fetch traffic data" + }); + } + }, + + /** + * Build request body for Google Routes API + * @param {object} config Module configuration + * @returns {object} Request body + */ + buildRequestBody (config) { + const requestBody = { + origin: this.buildWaypoint(config.origin, config.originLat, config.originLng), + destination: this.buildWaypoint(config.destination, config.destinationLat, config.destinationLng), + travelMode: "DRIVE", + routingPreference: config.routingPreference || "TRAFFIC_AWARE_OPTIMAL", + computeAlternativeRoutes: config.showAlternatives || false, + languageCode: config.language || "en", + units: config.units === "imperial" ? "IMPERIAL" : "METRIC", + extraComputations: config.extraComputations || [] + }; + + // Add departure time for traffic-aware routing + // if (config.departureTime) { + // requestBody.departureTime = config.departureTime; + // } else { + // Use current time + // const timeNow = new Date(); + // timeNow.setMinutes(timeNow.getMinutes() + 1); + // requestBody.departureTime = timeNow.toISOString(); + // } + + // Add traffic model + if (config.trafficModel) { + requestBody.trafficModel = config.trafficModel.toUpperCase(); + } + + // Add route modifiers (avoid options) + if (config.avoid && config.avoid.length > 0) { + requestBody.routeModifiers = {}; + config.avoid.forEach((avoid) => { + const upperAvoid = avoid.toUpperCase(); + if (upperAvoid === "TOLLS") { + requestBody.routeModifiers.avoidTolls = true; + } else if (upperAvoid === "HIGHWAYS") { + requestBody.routeModifiers.avoidHighways = true; + } else if (upperAvoid === "FERRIES") { + requestBody.routeModifiers.avoidFerries = true; + } else if (upperAvoid === "INDOOR") { + requestBody.routeModifiers.avoidIndoor = true; + } + }); + } + + return requestBody; + }, + + /** + * Build waypoint object from address or coordinates + * @param {string} address Address string + * @param {number} lat Latitude (optional) + * @param {number} lng Longitude (optional) + * @returns {object} Waypoint object + */ + buildWaypoint (address, lat, lng) { + if (lat !== undefined && lng !== undefined && !isNaN(lat) && !isNaN(lng)) { + return { + location: { + latLng: { + latitude: parseFloat(lat), + longitude: parseFloat(lng) + } + } + }; + } else if (address) { + return { + address: address + }; + } else { + throw new Error("Waypoint requires either address or coordinates"); + } + }, + + /** + * Make API request to Google Routes API + * @param {string} apiKey Google API key + * @param {object} requestBody Request body + * @returns {Promise} API response + */ + makeApiRequest (apiKey, requestBody) { + return new Promise((resolve, reject) => { + const postData = JSON.stringify(requestBody); + // const fieldMask = "routes.duration,routes.distanceMeters,routes.legs.duration,routes.legs.staticDuration,routes.legs.distanceMeters,routes.legs.steps,routes.summary,routes.warnings,routes.routeLabels,routes.polyline"; + const fieldMask = "routes.duration,routes.staticDuration,routes.distanceMeters,routes.travelAdvisory,routes.warnings,routes.polyline"; + const options = { + hostname: "routes.googleapis.com", + path: "/directions/v2:computeRoutes", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(postData), + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": fieldMask + } + }; + + const req = https.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + if (res.statusCode === 200) { + try { + const response = JSON.parse(data); + resolve(response); + } catch (error) { + reject(new Error(`Failed to parse API response: ${error.message}`)); + } + } else { + try { + const errorResponse = JSON.parse(data); + reject(new Error(errorResponse.error?.message || `API error: ${res.statusCode}`)); + } catch (error) { + reject(new Error(`API error: ${res.statusCode} - ${data}`)); + } + } + }); + }); + + req.on("error", (error) => { + reject(new Error(`Request failed: ${error.message}`)); + }); + + req.write(postData); + req.end(); + }); + }, + + /** + * Process API response and format for frontend + * @param {object} response API response + * @returns {object} Processed data + */ + processApiResponse (response) { + if (!response.routes || response.routes.length === 0) { + throw new Error("No routes found"); + } + + return { + routes: response.routes, + timestamp: new Date().toISOString() + }; + }, + + /** + * Update scheduler configuration and restart scheduler + * @param {object} config Scheduler configuration + */ + updateScheduler (config) { + // Clear existing timer + if (this.schedulerTimer) { + clearInterval(this.schedulerTimer); + this.schedulerTimer = null; + } + + // Reset state when config changes + this.lastEnabledState = null; + this.enabledAtTime = null; + + this.schedulerConfig = config; + + // If scheduler is disabled, stop here + if (!config || !config.schedulerEnabled) { + Log.info("[Traffic] Scheduler disabled"); + return; + } + + Log.info("[Traffic] Starting scheduler"); + // Check immediately + this.checkScheduler(); + // Check every minute + this.schedulerTimer = setInterval(() => { + this.checkScheduler(); + }, 60 * 1000); + }, + + /** + * Check scheduler and enable/disable module as needed + */ + checkScheduler () { + if (!this.schedulerConfig || !this.schedulerConfig.schedulerEnabled) { + return; + } + + const now = new Date(); + const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight + let shouldBeEnabled = false; + + if (this.schedulerConfig.schedulerMode === "duration") { + // Duration mode: enable for a specific duration after enableTime + const enableTime = this.parseTime(this.schedulerConfig.enableTime); + const duration = this.schedulerConfig.enabledDuration || 90; + + // Check if we're at the enable time (within 1 minute window) + if (currentTime >= enableTime && currentTime < enableTime + 1) { + // Time to enable - mark the enable time + this.enabledAtTime = currentTime; + shouldBeEnabled = true; + } else if (this.enabledAtTime !== null) { + // Check if we're still within the duration window + let timeSinceEnable; + if (currentTime >= this.enabledAtTime) { + timeSinceEnable = currentTime - this.enabledAtTime; + } else { + // Wrapped around midnight + timeSinceEnable = (24 * 60 - this.enabledAtTime) + currentTime; + } + + if (timeSinceEnable < duration) { + shouldBeEnabled = true; + } else { + // Duration expired, reset + this.enabledAtTime = null; + shouldBeEnabled = false; + } + } else { + // Not enabled yet and not at enable time + shouldBeEnabled = false; + } + } else { + // Time range mode: enable between enableTime and disableTime + const enableTime = this.parseTime(this.schedulerConfig.enableTime); + let disableTime = this.parseTime(this.schedulerConfig.disableTime); + + // If disableTime is not set, calculate it as enableTime + 90 minutes + if (!disableTime && enableTime) { + disableTime = enableTime + 90; + // Handle wrap-around past midnight + if (disableTime >= 24 * 60) { + disableTime = disableTime % (24 * 60); + } + } + + if (enableTime < disableTime) { + // Normal case: enableTime < disableTime (e.g., 08:00 to 18:00) + shouldBeEnabled = currentTime >= enableTime && currentTime < disableTime; + } else { + // Wraps around midnight (e.g., 22:00 to 06:00) + shouldBeEnabled = currentTime >= enableTime || currentTime < disableTime; + } + } + + // Send notification if state should change + // We'll track the last state to avoid sending duplicate notifications + if (shouldBeEnabled && this.lastEnabledState !== true) { + Log.info("[Traffic] Scheduler: Enabling module"); + this.sendSocketNotification("SCHEDULER_ENABLED", {}); + this.lastEnabledState = true; + } else if (!shouldBeEnabled && this.lastEnabledState !== false) { + Log.info("[Traffic] Scheduler: Disabling module"); + this.sendSocketNotification("SCHEDULER_DISABLED", {}); + this.lastEnabledState = false; + // Reset enabledAtTime when disabling + if (this.schedulerConfig.schedulerMode === "duration") { + this.enabledAtTime = null; + } + } + }, + + /** + * Parse time string (HH:MM) to minutes since midnight + * @param {string} timeStr Time string in HH:MM format + * @returns {number} Minutes since midnight + */ + parseTime (timeStr) { + if (!timeStr || typeof timeStr !== "string") { + return 0; + } + const parts = timeStr.split(":"); + if (parts.length !== 2) { + return 0; + } + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + if (isNaN(hours) || isNaN(minutes)) { + return 0; + } + return hours * 60 + minutes; + } +}); + diff --git a/modules/default/traffic/traffic.css b/modules/default/traffic/traffic.css new file mode 100644 index 0000000000..363caba76b --- /dev/null +++ b/modules/default/traffic/traffic.css @@ -0,0 +1,180 @@ +.traffic-container { + display: flex; + flex-direction: column; + gap: 10px; + max-height: inherit; /* Respect parent max-height */ + overflow-y: auto; /* Allow scrolling if needed */ +} + +.traffic-map-container { + width: 100%; + height: 200px; + min-height: 200px; + border-radius: 4px; + overflow: hidden; + margin-bottom: 10px; + background-color: rgb(0 0 0 / 20%); +} + +.traffic-map-container .leaflet-container { + width: 100%; + height: 100%; + background-color: rgb(0 0 0 / 20%); +} + +.traffic-map-container .leaflet-tile-container img { + filter: brightness(0.7) contrast(1.2); +} + +.traffic-marker { + background-color: transparent; + border: none; + text-align: center; + line-height: 12px; + font-size: 12px; +} + +.traffic-marker-origin { + color: #4caf50; +} + +.traffic-marker-destination { + color: #f44336; +} + +.traffic-route { + margin-bottom: 8px; +} + +.traffic-route-info { + font-size: 0.85em; +} + +.traffic-origin-dest { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.traffic-origin, +.traffic-destination { + color: #aaa; +} + +.traffic-arrow { + color: #888; + font-weight: bold; +} + +.traffic-metrics { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9em; +} + +.traffic-metric { + display: flex; + align-items: baseline; + gap: 8px; +} + +.traffic-label { + color: #aaa; + min-width: 60px; +} + +.traffic-value { + color: #fff; + font-weight: 500; +} + +.traffic-delay { + color: #ff9800; + font-size: 0.9em; +} + +.traffic-level { + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + display: inline-block; +} + +.traffic-level-light { + background-color: rgb(76 175 80 / 30%); + color: #4caf50; +} + +.traffic-level-moderate { + background-color: rgb(255 235 59 / 30%); + color: #ffeb3b; +} + +.traffic-level-heavy { + background-color: rgb(255 152 0 / 30%); + color: #ff9800; +} + +.traffic-level-severe { + background-color: rgb(244 67 54 / 30%); + color: #f44336; +} + +.traffic-alternatives { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgb(255 255 255 / 10%); + font-size: 0.85em; +} + +.traffic-alternatives-header { + color: #aaa; + margin-bottom: 5px; + font-weight: 500; +} + +.traffic-alternative { + margin-left: 10px; + color: #ccc; +} + +.traffic-alt-info { + font-style: italic; +} + +.traffic-loading, +.traffic-error, +.traffic-empty { + padding: 10px; + text-align: center; +} + +.traffic-error { + color: #f44336; +} + +.traffic-warnings { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgb(255 255 255 / 10%); + font-size: 0.85em; +} + +.traffic-warning { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 5px; + color: #ff9800; +} + +.traffic-warning-icon { + font-size: 1.1em; + flex-shrink: 0; +} + +.traffic-warning-text { + flex: 1; +} diff --git a/modules/default/traffic/traffic.js b/modules/default/traffic/traffic.js new file mode 100644 index 0000000000..b1e02704e1 --- /dev/null +++ b/modules/default/traffic/traffic.js @@ -0,0 +1,826 @@ +/* global L */ +Module.register("traffic", { + // Default module config. + defaults: { + apiKey: "", + origin: "", + destination: "", + updateInterval: 5 * 60 * 1000, // 5 minutes + initialLoadDelay: 0, + showAlternatives: false, + extraComputations: [], // TRAFFIC_ON_POLYLINE, TRAFFIC_ON_ROUTE + trafficModel: "BEST_GUESS", // BEST_GUESS, PESSIMISTIC, OPTIMISTIC + routingPreference: "TRAFFIC_AWARE_OPTIMAL", // TRAFFIC_AWARE, TRAFFIC_AWARE_OPTIMAL + avoid: [], // ["TOLLS", "HIGHWAYS", "FERRIES", "INDOOR"] + units: config.units, // metric or imperial + showDistance: true, + showDuration: true, + showTrafficDelay: true, + showTrafficLevel: true, + header: "Traffic", + animationSpeed: 1000, + fade: true, + fadePoint: 0.25, + disabled: false, + // Scheduler configuration + schedulerEnabled: false, + schedulerMode: "timeRange", // "timeRange" or "duration" + enableTime: "06:30", // HH:MM format + disableTime: "08:00", // HH:MM format + enabledDuration: 90 // minutes (used when schedulerMode is "duration") + }, + + // Module properties. + trafficData: null, + routes: [], + lastUpdate: null, + error: null, + updateTimer: null, + loaded: false, + disabled: false, + map: null, + mapInitialized: false, + routeLayer: null, + markers: [], + + // Define required styles. + getStyles () { + return ["traffic.css", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"]; + }, + + // Define required scripts. + getScripts () { + return ["https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"]; + }, + + // Define start sequence. + start () { + Log.info(`Starting module: ${this.name}`); + + // Check if module is disabled + this.disabled = this.config.disabled || false; + if (this.disabled) { + Log.info("[Traffic] Module is disabled"); + this.loaded = true; + this.updateDom(); + return; + } + + // Validate configuration + if (!this.config.apiKey) { + this.error = "API key is required"; + Log.error("[Traffic] API key is missing in configuration"); + this.disabled = true; + this.loaded = true; + this.updateDom(); + return; + } + + if (!this.config.origin || !this.config.destination) { + Log.warn("[Traffic] Origin and destination are missing in configuration - disabling module"); + this.disabled = true; + this.loaded = true; + this.updateDom(); + return; + } + + // Initialize data + this.trafficData = null; + this.routes = []; + this.error = null; + this.loaded = false; + + // Send initial request to node helper + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + + // Initialize scheduler if enabled + if (this.config.schedulerEnabled) { + // Calculate disableTime if not set (enableTime + 90 minutes) + let disableTime = this.config.disableTime; + if (!disableTime && this.config.enableTime) { + disableTime = this.calculateDisableTime(this.config.enableTime, 90); + } + + this.sendSocketNotification("SCHEDULER_CONFIG_UPDATED", { + schedulerEnabled: this.config.schedulerEnabled, + schedulerMode: this.config.schedulerMode || "timeRange", + enableTime: this.config.enableTime || "08:00", + disableTime: disableTime || "09:30", + enabledDuration: this.config.enabledDuration || 90 + }); + } + + // Schedule periodic updates + this.scheduleUpdate(this.config.initialLoadDelay); + }, + + // Override socket notification handler. + socketNotificationReceived (notification, payload) { + if (notification === "TRAFFIC_DATA") { + if (this.disabled) return; + this.processTrafficData(payload); + this.loaded = true; + this.error = null; + this.updateDom(this.config.animationSpeed); + // Initialize map after DOM update + setTimeout(() => { + if (!this.mapInitialized && typeof L !== "undefined") { + this.initializeMap(); + } + }, 2500); + } else if (notification === "TRAFFIC_ERROR") { + if (this.disabled) return; + this.error = payload.error || "Failed to fetch traffic data"; + this.loaded = true; + Log.error(`[Traffic] Error: ${this.error}`); + this.updateDom(this.config.animationSpeed); + // Schedule retry + this.scheduleUpdate(); + } else if (notification === "MODULE_CONFIG_UPDATED") { + this.handleConfigUpdate(payload); + } else if (notification === "MODULE_DISABLED") { + this.handleDisabled(); + } else if (notification === "MODULE_ENABLED") { + this.handleEnabled(); + } else if (notification === "SCHEDULER_ENABLED") { + this.handleEnabled(); + } else if (notification === "SCHEDULER_DISABLED") { + this.handleDisabled(); + } + }, + + /** + * Process traffic data received from node helper + * @param {object} data Traffic data from API + */ + processTrafficData (data) { + this.trafficData = data; + this.routes = data.routes || []; + this.lastUpdate = new Date(); + Log.info(`[Traffic] Received traffic data: ${this.routes.length} route(s)`); + // Update map if initialized + if (this.mapInitialized) { + this.updateMap(); + } + }, + + /** + * Schedule the next update + * @param {number} delay Optional delay in milliseconds + */ + scheduleUpdate (delay = null) { + // Clear existing timer + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + + let nextLoad = this.config.updateInterval; + if (delay !== null && delay >= 0) { + nextLoad = delay; + } + + this.updateTimer = setTimeout(() => { + // Request fresh data from node helper + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + // Schedule next update (recursive) + this.scheduleUpdate(); + }, nextLoad); + }, + + // Override getTemplate method. + getTemplate () { + return "traffic.njk"; + }, + + // Override getTemplateData method. + getTemplateData () { + // Check if module is disabled + if (this.disabled) { + return { + disabled: true, + loaded: true + }; + } + + if (this.error) { + return { + error: this.error, + loaded: this.loaded + }; + } + + if (!this.loaded || !this.trafficData || this.routes.length === 0) { + return { + loading: true, + loaded: this.loaded + }; + } + + // Get primary route (first route) + const primaryRoute = this.routes[0]; + if (!primaryRoute) { + return { + error: "No route data available", + loaded: this.loaded + }; + } + + // Calculate traffic metrics + const metrics = this.calculateTrafficMetrics(primaryRoute); + + return { + loaded: true, + config: this.config, + route: primaryRoute, + metrics: metrics, + lastUpdate: this.lastUpdate, + alternatives: this.config.showAlternatives ? this.routes.slice(1) : [], + showMap: true + }; + }, + + /** + * Calculate traffic metrics from route data + * @param {object} route Route object from API + * @returns {object} Traffic metrics + */ + calculateTrafficMetrics (route) { + if (!route) { + return null; + } + + // Get duration and staticDuration from route (API returns at top level) + const totalDuration = this.parseDuration(route.duration || "0s"); + const totalStaticDuration = this.parseDuration(route.staticDuration || "0s"); + const totalDistance = route.distanceMeters || 0; + + // Calculate delay + const delaySeconds = totalDuration - totalStaticDuration; + const delayPercent = totalStaticDuration > 0 ? (delaySeconds / totalStaticDuration) * 100 : 0; + + // Determine traffic level + let trafficLevel = "light"; + let trafficLevelLabel = "Light"; + if (delayPercent >= 50) { + trafficLevel = "severe"; + trafficLevelLabel = "Severe"; + } else if (delayPercent >= 30) { + trafficLevel = "heavy"; + trafficLevelLabel = "Heavy"; + } else if (delayPercent >= 10) { + trafficLevel = "moderate"; + trafficLevelLabel = "Moderate"; + } + + // Format distance + const distance = this.formatDistance(totalDistance); + + // Format durations + const duration = this.formatDuration(totalDuration); + const staticDuration = this.formatDuration(totalStaticDuration); + const delay = this.formatDuration(delaySeconds); + + return { + duration: duration, + staticDuration: staticDuration, + delay: delay, + delaySeconds: delaySeconds, + delayPercent: delayPercent.toFixed(1), + trafficLevel: trafficLevel, + trafficLevelLabel: trafficLevelLabel, + distance: distance, + distanceMeters: totalDistance + }; + }, + + /** + * Parse duration object from API (e.g., "3600s" or {seconds: 3600}) + * @param {string|object} duration Duration from API + * @returns {number} Duration in seconds + */ + parseDuration (duration) { + if (typeof duration === "string") { + // Format: "3600s" + const match = duration.match(/(\d+)s/); + return match ? parseInt(match[1], 10) : 0; + } else if (duration && typeof duration === "object") { + // Format: {seconds: 3600} or {seconds: 3600, nanos: 0} + return duration.seconds || 0; + } + return 0; + }, + + /** + * Format duration in seconds to human-readable string + * @param {number} seconds Duration in seconds + * @returns {string} Formatted duration (e.g., "45 min", "1h 30 min") + */ + formatDuration (seconds) { + if (seconds < 60) { + return `${seconds} sec`; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes} min` : `${hours}h`; + } + return `${minutes} min`; + }, + + /** + * Format distance in meters to human-readable string + * @param {number} meters Distance in meters + * @returns {string} Formatted distance + */ + formatDistance (meters) { + if (this.config.units === "imperial") { + const miles = meters / 1609.34; + if (miles < 0.1) { + const feet = meters * 3.28084; + return `${Math.round(feet)} ft`; + } + return `${miles.toFixed(1)} mi`; + } else { + // metric + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + const km = meters / 1000; + return `${km.toFixed(1)} km`; + } + }, + + /** + * Calculate disable time from enable time + duration in minutes + * @param {string} enableTime Time string in HH:MM format + * @param {number} durationMinutes Duration in minutes + * @returns {string} Disable time in HH:MM format + */ + calculateDisableTime (enableTime, durationMinutes) { + if (!enableTime || typeof enableTime !== "string") { + return "09:30"; // Default fallback + } + const parts = enableTime.split(":"); + if (parts.length !== 2) { + return "09:30"; // Default fallback + } + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + if (isNaN(hours) || isNaN(minutes)) { + return "09:30"; // Default fallback + } + + // Calculate total minutes since midnight + let totalMinutes = hours * 60 + minutes + durationMinutes; + + // Handle wrap-around past midnight + if (totalMinutes >= 24 * 60) { + totalMinutes = totalMinutes % (24 * 60); + } + + const disableHours = Math.floor(totalMinutes / 60); + const disableMinutes = totalMinutes % 60; + + // Format as HH:MM + return `${String(disableHours).padStart(2, "0")}:${String(disableMinutes).padStart(2, "0")}`; + }, + + /** + * Called when module is about to be hidden + */ + suspend () { + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + // Clean up map + if (this.map) { + this.map.remove(); + this.map = null; + this.mapInitialized = false; + this.routeLayer = null; + this.markers = []; + } + }, + + /** + * Called when module is about to be shown + */ + resume () { + // Resume updates if needed + if (!this.updateTimer && this.loaded && !this.disabled) { + this.scheduleUpdate(); + } + // Initialize map if not already done + setTimeout(() => { + if (!this.mapInitialized && this.loaded && !this.disabled && this.routes.length > 0 && typeof L !== "undefined") { + this.initializeMap(); + } + }, 100); + }, + + /** + * Initialize Leaflet map + */ + initializeMap () { + if (typeof L === "undefined") { + Log.warn("[Traffic] Leaflet library not loaded yet"); + return; + } + + const moduleWrapper = document.getElementById(this.identifier); + if (!moduleWrapper) { + Log.warn("[Traffic] Module wrapper not found"); + return; + } + + const mapContainer = moduleWrapper.querySelector(".traffic-map-container"); + if (!mapContainer) { + Log.warn("[Traffic] Map container not found"); + return; + } + + try { + // Get route bounds or use default center + let center = [37.7749, -122.4194]; // Default to San Francisco + let zoom = 10; + + if (this.routes.length > 0 && this.routes[0].polyline) { + // Decode polyline to get route bounds + const polyline = this.routes[0].polyline.encodedPolyline; + if (polyline) { + const decoded = this.decodePolyline(polyline); + if (decoded.length > 0) { + // Calculate bounds + const bounds = this.calculateBounds(decoded); + center = [(bounds.north + bounds.south) / 2, (bounds.east + bounds.west) / 2]; + zoom = this.calculateZoom(bounds); + } + } + } + + // Initialize map + this.map = L.map(mapContainer, { + zoomControl: false, + attributionControl: false, + scrollWheelZoom: false, + doubleClickZoom: false, + dragging: false, + touchZoom: false, + boxZoom: false, + keyboard: false + }); + + // Add tile layer (OpenStreetMap) + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "" + }).addTo(this.map); + + // Set view + this.map.setView(center, zoom); + + this.mapInitialized = true; + Log.info("[Traffic] Map initialized"); + + // Update map with route data + this.updateMap(); + } catch (error) { + Log.error("[Traffic] Error initializing map:", error); + } + }, + + /** + * Update map with current route data + */ + updateMap () { + if (!this.map || !this.mapInitialized || this.routes.length === 0) { + return; + } + + try { + // Remove existing route layer and markers + if (this.routeLayer) { + this.map.removeLayer(this.routeLayer); + this.routeLayer = null; + } + this.markers.forEach((marker) => { + this.map.removeLayer(marker); + }); + this.markers = []; + + const primaryRoute = this.routes[0]; + if (!primaryRoute.polyline || !primaryRoute.polyline.encodedPolyline) { + return; + } + + // Decode polyline + const decoded = this.decodePolyline(primaryRoute.polyline.encodedPolyline); + if (decoded.length === 0) { + return; + } + + // Add route polyline + const latlngs = decoded.map((point) => [point.lat, point.lng]); + this.routeLayer = L.polyline(latlngs, { + color: this.getRouteColor(primaryRoute), + weight: 4, + opacity: 0.8 + }).addTo(this.map); + + // Add origin marker + if (decoded.length > 0) { + const origin = decoded[0]; + const originMarker = L.marker([origin.lat, origin.lng], { + icon: L.divIcon({ + className: "traffic-marker traffic-marker-origin", + html: "●", + iconSize: [12, 12] + }) + }).addTo(this.map); + this.markers.push(originMarker); + } + + // Add destination marker + if (decoded.length > 1) { + const destination = decoded[decoded.length - 1]; + const destMarker = L.marker([destination.lat, destination.lng], { + icon: L.divIcon({ + className: "traffic-marker traffic-marker-destination", + html: "●", + iconSize: [12, 12] + }) + }).addTo(this.map); + this.markers.push(destMarker); + } + + // Fit map to route bounds + const bounds = this.calculateBounds(decoded); + this.map.fitBounds([ + [bounds.south, bounds.west], + [bounds.north, bounds.east] + ], { + padding: [20, 20] + }); + } catch (error) { + Log.error("[Traffic] Error updating map:", error); + } + }, + + /** + * Decode Google encoded polyline + * @param {string} encoded Encoded polyline string + * @returns {Array} Array of {lat, lng} objects + */ + decodePolyline (encoded) { + const points = []; + let index = 0; + const len = encoded.length; + let lat = 0; + let lng = 0; + + while (index < len) { + let b; + let shift = 0; + let result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlat = ((result & 1) !== 0) ? ~(result >> 1) : (result >> 1); + lat += dlat; + + shift = 0; + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlng = ((result & 1) !== 0) ? ~(result >> 1) : (result >> 1); + lng += dlng; + + points.push({ + lat: lat * 1e-5, + lng: lng * 1e-5 + }); + } + + return points; + }, + + /** + * Calculate bounds from decoded polyline points + * @param {Array} points Array of {lat, lng} objects + * @returns {object} Bounds object with north, south, east, west + */ + calculateBounds (points) { + if (points.length === 0) { + return { north: 0, south: 0, east: 0, west: 0 }; + } + + let north = points[0].lat; + let south = points[0].lat; + let east = points[0].lng; + let west = points[0].lng; + + points.forEach((point) => { + north = Math.max(north, point.lat); + south = Math.min(south, point.lat); + east = Math.max(east, point.lng); + west = Math.min(west, point.lng); + }); + + return { north, south, east, west }; + }, + + /** + * Calculate appropriate zoom level from bounds + * @param {object} bounds Bounds object + * @returns {number} Zoom level + */ + calculateZoom (bounds) { + const latDiff = bounds.north - bounds.south; + const lngDiff = bounds.east - bounds.west; + const maxDiff = Math.max(latDiff, lngDiff); + + if (maxDiff > 10) return 5; + if (maxDiff > 5) return 6; + if (maxDiff > 2) return 7; + if (maxDiff > 1) return 8; + if (maxDiff > 0.5) return 9; + if (maxDiff > 0.2) return 10; + if (maxDiff > 0.1) return 11; + if (maxDiff > 0.05) return 12; + if (maxDiff > 0.02) return 13; + return 14; + }, + + /** + * Get route color based on traffic level + * @param {object} route Route object + * @returns {string} Color hex code + */ + getRouteColor (route) { + // Calculate traffic delay percentage + const totalDuration = this.parseDuration(route.duration || "0s"); + const totalStaticDuration = this.parseDuration(route.staticDuration || "0s"); + const delayPercent = totalStaticDuration > 0 ? ((totalDuration - totalStaticDuration) / totalStaticDuration) * 100 : 0; + + if (delayPercent >= 50) { + return "#f44336"; // Red - Severe + } else if (delayPercent >= 30) { + return "#ff9800"; // Orange - Heavy + } else if (delayPercent >= 10) { + return "#ffeb3b"; // Yellow - Moderate + } + return "#4caf50"; // Green - Light + }, + + /** + * Handle configuration update from admin page + * @param {object} updates Configuration updates + */ + handleConfigUpdate (updates) { + Log.info("[Traffic] Configuration updated via admin page"); + + // Update config values + if (updates.destination !== undefined) { + this.config.destination = updates.destination; + } + if (updates.trafficModel !== undefined) { + this.config.trafficModel = updates.trafficModel; + } + if (updates.updateInterval !== undefined) { + this.config.updateInterval = updates.updateInterval; + } + if (updates.avoid !== undefined) { + this.config.avoid = updates.avoid; + } + + // Update scheduler config + if (updates.schedulerEnabled !== undefined) { + this.config.schedulerEnabled = updates.schedulerEnabled; + } + if (updates.schedulerMode !== undefined) { + this.config.schedulerMode = updates.schedulerMode; + } + if (updates.enableTime !== undefined) { + this.config.enableTime = updates.enableTime; + } + if (updates.disableTime !== undefined) { + this.config.disableTime = updates.disableTime; + } + if (updates.enabledDuration !== undefined) { + this.config.enabledDuration = updates.enabledDuration; + } + + // Send scheduler config update to node_helper + if (updates.schedulerEnabled !== undefined || updates.schedulerMode !== undefined) { + // Calculate disableTime if not set (enableTime + 90 minutes) + let disableTime = this.config.disableTime; + if (!disableTime && this.config.enableTime) { + disableTime = this.calculateDisableTime(this.config.enableTime, 90); + } + + this.sendSocketNotification("SCHEDULER_CONFIG_UPDATED", { + schedulerEnabled: this.config.schedulerEnabled || false, + schedulerMode: this.config.schedulerMode || "timeRange", + enableTime: this.config.enableTime || "08:00", + disableTime: disableTime || "09:30", + enabledDuration: this.config.enabledDuration || 90 + }); + } + + // Check if we have required config (origin and destination) + if (!this.config.origin || !this.config.destination) { + Log.warn("[Traffic] Origin or destination missing after update - disabling module"); + this.disabled = true; + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + this.updateDom(); + return; + } + + // Restart updates with new interval if module is enabled + if (!this.disabled) { + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + // Re-validate and restart + if (this.config.apiKey && this.config.origin && this.config.destination) { + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + this.scheduleUpdate(); + } + } + + this.updateDom(); + }, + + /** + * Handle module being disabled + */ + handleDisabled () { + Log.info("[Traffic] Module disabled via admin page"); + this.disabled = true; + this.config.disabled = true; + + // Stop updates + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + } + + // Clean up map + if (this.map) { + this.map.remove(); + this.map = null; + this.mapInitialized = false; + this.routeLayer = null; + this.markers = []; + } + + this.updateDom(); + }, + + /** + * Handle module being enabled + */ + handleEnabled () { + Log.info("[Traffic] Module enabled via admin page"); + + // Check if we have required config (origin and destination) + if (!this.config.origin || !this.config.destination) { + Log.warn("[Traffic] Origin or destination missing - cannot enable module"); + this.disabled = true; + this.loaded = true; + this.updateDom(); + return; + } + + this.disabled = false; + this.config.disabled = false; + + // Restart if we have valid config + if (this.config.apiKey && this.config.origin && this.config.destination) { + this.loaded = false; + this.error = null; + this.sendSocketNotification("FETCH_TRAFFIC", this.config); + this.scheduleUpdate(this.config.initialLoadDelay); + } else { + this.error = "API key, origin, and destination are required"; + this.disabled = true; + this.loaded = true; + } + + this.updateDom(); + } +}); + diff --git a/modules/default/traffic/traffic.njk b/modules/default/traffic/traffic.njk new file mode 100644 index 0000000000..7485f8259b --- /dev/null +++ b/modules/default/traffic/traffic.njk @@ -0,0 +1,80 @@ +{% if disabled %} +
+
Traffic Module Disabled
+
+{% elif loading %} +
+
Loading traffic data...
+
+{% elif error %} +
+
{{ error }}
+
+{% elif loaded and route and metrics %} +
+ {% if showMap %} +
+ {% endif %} +
+
+
+ {{ config.origin }} + + {{ config.destination }} +
+
+
+ +
+ {% if config.showDuration %} +
+ Time: + {{ metrics.duration }} + {% if config.showTrafficDelay and metrics.delaySeconds > 0 %} + (+{{ metrics.delay }}) + {% endif %} +
+ {% endif %} + + {% if config.showDistance %} +
+ Distance: + {{ metrics.distance }} +
+ {% endif %} + + {% if config.showTrafficLevel %} +
+ Traffic: + {{ metrics.trafficLevelLabel }} +
+ {% endif %} +
+ + {% if route.warnings and route.warnings.length > 0 %} +
+ {% for warning in route.warnings %} +
+ + {{ warning }} +
+ {% endfor %} +
+ {% endif %} + + {% if config.showAlternatives and alternatives.length > 0 %} +
+
Alternative routes:
+ {% for altRoute in alternatives %} +
+ Alternative route {{ loop.index }} +
+ {% endfor %} +
+ {% endif %} +
+{% else %} +
+
No traffic data available
+
+{% endif %} diff --git a/modules/default/weather/weather.js b/modules/default/weather/weather.js index 8a81b276e1..32a4a9f813 100644 --- a/modules/default/weather/weather.js +++ b/modules/default/weather/weather.js @@ -3,9 +3,9 @@ Module.register("weather", { // Default module config. defaults: { - weatherProvider: "openweathermap", + weatherProvider: "weathergov", roundTemp: false, - type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) + type: "hourly", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) lang: config.language, units: config.units, tempUnits: config.units, diff --git a/tests/configs/modules/compliments/compliments_animateCSS.js b/tests/configs/modules/compliments/compliments_animateCSS.js index 505b7d4d74..7ae0605e6a 100644 --- a/tests/configs/modules/compliments/compliments_animateCSS.js +++ b/tests/configs/modules/compliments/compliments_animateCSS.js @@ -11,7 +11,7 @@ let config = { compliments: { anytime: ["AnimateCSS Testing..."] }, - updateInterval: 2000, + updateInterval: 1000, fadeSpeed: 1000 } } diff --git a/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js b/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js index fa98a5645f..f03b708455 100644 --- a/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js +++ b/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js @@ -11,7 +11,7 @@ let config = { compliments: { anytime: ["AnimateCSS Testing..."] }, - updateInterval: 2000, + updateInterval: 1000, fadeSpeed: 1000 } } diff --git a/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js b/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js index 863b8c7702..665ee002c6 100644 --- a/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js +++ b/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js @@ -11,7 +11,7 @@ let config = { compliments: { anytime: ["AnimateCSS Testing..."] }, - updateInterval: 2000, + updateInterval: 1000, fadeSpeed: 1000 } } diff --git a/tests/configs/modules/compliments/compliments_anytime.js b/tests/configs/modules/compliments/compliments_anytime.js index 5491f6a138..4a2cd2e6f8 100644 --- a/tests/configs/modules/compliments/compliments_anytime.js +++ b/tests/configs/modules/compliments/compliments_anytime.js @@ -8,6 +8,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, compliments: { morning: [], afternoon: [], diff --git a/tests/configs/modules/compliments/compliments_cron_entry.js b/tests/configs/modules/compliments/compliments_cron_entry.js index a2010d5e96..5884af3873 100644 --- a/tests/configs/modules/compliments/compliments_cron_entry.js +++ b/tests/configs/modules/compliments/compliments_cron_entry.js @@ -6,6 +6,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, specialDayUnique: true, compliments: { anytime: ["just a test"], diff --git a/tests/configs/modules/compliments/compliments_date.js b/tests/configs/modules/compliments/compliments_date.js index 80c0730902..3c83724fbf 100644 --- a/tests/configs/modules/compliments/compliments_date.js +++ b/tests/configs/modules/compliments/compliments_date.js @@ -8,6 +8,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, compliments: { morning: [], afternoon: [], diff --git a/tests/configs/modules/compliments/compliments_e2e_cron_entry.js b/tests/configs/modules/compliments/compliments_e2e_cron_entry.js index eaba541241..c6dacc39e1 100644 --- a/tests/configs/modules/compliments/compliments_e2e_cron_entry.js +++ b/tests/configs/modules/compliments/compliments_e2e_cron_entry.js @@ -6,6 +6,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, specialDayUnique: true, compliments: { anytime: ["just a test"], diff --git a/tests/configs/modules/compliments/compliments_evening.js b/tests/configs/modules/compliments/compliments_evening.js index d7bf2242f2..b63133caa1 100644 --- a/tests/configs/modules/compliments/compliments_evening.js +++ b/tests/configs/modules/compliments/compliments_evening.js @@ -8,6 +8,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, compliments: { evening: ["Evening here"] } diff --git a/tests/configs/modules/compliments/compliments_file.js b/tests/configs/modules/compliments/compliments_file.js index d73e1b5c10..1b92626a24 100644 --- a/tests/configs/modules/compliments/compliments_file.js +++ b/tests/configs/modules/compliments/compliments_file.js @@ -6,7 +6,7 @@ let config = { module: "compliments", position: "bottom_bar", config: { - updateInterval: 3000, + updateInterval: 1000, remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json" } } diff --git a/tests/configs/modules/compliments/compliments_file_change.js b/tests/configs/modules/compliments/compliments_file_change.js index 51fd4f6408..95bc9f1ab8 100644 --- a/tests/configs/modules/compliments/compliments_file_change.js +++ b/tests/configs/modules/compliments/compliments_file_change.js @@ -6,7 +6,7 @@ let config = { module: "compliments", position: "bottom_bar", config: { - updateInterval: 3000, + updateInterval: 1000, remoteFileRefreshInterval: 1500, remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json", remoteFile2: "http://localhost:8080/tests/mocks/compliments_file.json" diff --git a/tests/configs/modules/compliments/compliments_only_anytime.js b/tests/configs/modules/compliments/compliments_only_anytime.js index 09b3cf22f9..791323b873 100644 --- a/tests/configs/modules/compliments/compliments_only_anytime.js +++ b/tests/configs/modules/compliments/compliments_only_anytime.js @@ -8,6 +8,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, compliments: { anytime: ["Anytime here"] } diff --git a/tests/configs/modules/compliments/compliments_parts_day.js b/tests/configs/modules/compliments/compliments_parts_day.js index a38e0e22af..24439401ad 100644 --- a/tests/configs/modules/compliments/compliments_parts_day.js +++ b/tests/configs/modules/compliments/compliments_parts_day.js @@ -8,6 +8,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, compliments: { morning: ["Hi", "Good Morning", "Morning test"], afternoon: ["Hello", "Good Afternoon", "Afternoon test"], diff --git a/tests/configs/modules/compliments/compliments_remote.js b/tests/configs/modules/compliments/compliments_remote.js index 844f14fdb5..b99c302bdb 100644 --- a/tests/configs/modules/compliments/compliments_remote.js +++ b/tests/configs/modules/compliments/compliments_remote.js @@ -6,6 +6,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json" } } diff --git a/tests/configs/modules/compliments/compliments_specialDayUnique_false.js b/tests/configs/modules/compliments/compliments_specialDayUnique_false.js index 1effd099ce..fd35846a92 100644 --- a/tests/configs/modules/compliments/compliments_specialDayUnique_false.js +++ b/tests/configs/modules/compliments/compliments_specialDayUnique_false.js @@ -6,6 +6,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, specialDayUnique: false, compliments: { anytime: [ diff --git a/tests/configs/modules/compliments/compliments_specialDayUnique_true.js b/tests/configs/modules/compliments/compliments_specialDayUnique_true.js index 6e10799d71..d9094601f7 100644 --- a/tests/configs/modules/compliments/compliments_specialDayUnique_true.js +++ b/tests/configs/modules/compliments/compliments_specialDayUnique_true.js @@ -6,6 +6,7 @@ let config = { module: "compliments", position: "middle_center", config: { + updateInterval: 1000, specialDayUnique: true, compliments: { anytime: [