Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
513 changes: 513 additions & 0 deletions MMM-MyScoreboard.css

Large diffs are not rendered by default.

311 changes: 281 additions & 30 deletions MMM-MyScoreboard.js

Large diffs are not rendered by default.

29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil
config: {
viewStyle: "stackedWithLogos",
showPlayoffStatus: true,
showBaseballDetail: true,
showDetail: true,
sports: [
{
league: "NHL",
Expand Down Expand Up @@ -96,15 +96,38 @@ Add MMM-MyScoreboard module to the `modules` array in the `config/config.js` fil
| `maxHeight` | Limit of the height of the container (in pixels). If there are more games than will fit within this height, a scroll animation will start. For best results, set the limit to show exactly the number of games you want when static. This ensures that any extra games will be fully hidden. No animation will occur if there are fewer games than fill the container.<br><br>**Type** `Int`<br>**Default** `10000` (will not set a height limit)
| `scrollSpeed` | Speed for scrolling. _lower_ numbers are _faster_.<br><br>**Type** `Int`<br>**Default** `6`
| `showPlayoffStatus` | Displays information about playoff series when set to `true`. (Currently only works with `stacked` and `stackedWithLogos` views without adding custom css.)<br><br>**Type** `Boolean`<br>**Default** `false`
| `showBaseballDetail` | When set to `true`, in-progress baseball games (MLB, NCAAB, WBC) display the current game situation: base runners on a diamond indicator, ball-strike count, outs, and the current pitcher/batter matchup. Pitcher and batter are hidden between half-innings.<br><br>**Type** `Boolean`<br>**Default** `false`
| `baseballDetailInterval` | When `showBaseballDetail` is enabled and a baseball game is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only baseball leagues are polled at this faster rate.<br><br>**Type** `Number`<br>**Default** `15` (seconds, minimum `1`)
| `showDetail` | When set to `true`, in-progress games in supported sports display the live game situation.<br><br>Baseball (MLB, NCAAB, WBC): base runners on a diamond indicator, ball-strike count, outs, and the current pitcher/batter matchup (hidden between half-innings).<br><br>Football (NFL, NCAAF): down & distance and field position, an animated gridiron graphic showing the ball spot and first-down line (the ball slides on each new play, with a green/red trail showing yards gained or lost), the last play description below the field, a possession indicator under the logo of the team with the ball, and timeouts remaining under each score. The field position, possession indicator, and ball marker turn red when the offense is in the red zone. On a touchdown the ball slides into the end zone and stays there until the ensuing kickoff.<br><br>**Type** `Boolean`<br>**Default** `false`
| `detailInterval` | When `showDetail` is enabled and a game in a supported sport is in-progress, the module polls for score updates at this interval (in seconds) instead of the default 2-minute interval. Only leagues of the affected sport are polled at this faster rate.<br><br>**Type** `Number`<br>**Default** `15` (seconds, minimum `1`)
| `detailViewOverride` | When `showDetail` is enabled, forces the `largeLogos` view for in-progress games showing detail so it has room to render. Set to `false` to keep your configured `viewStyle` (detail only renders in the `largeLogos` and `mediumLogos` views).<br><br>**Type** `Boolean`<br>**Default** `true`
| `showScoreAnimation` | When set to `true`, a firework animation plays on the score element whenever a followed team scores. The animation type varies by sport: low-scoring sports (MLB, NFL, NHL, soccer) get a firework burst, high-scoring sports (NBA, college basketball) get a quick flash, and an extended celebration with multiple staggered fireworks plays when a followed team wins.<br><br>**Type** `Boolean`<br>**Default** `false`
| `showUpcomingGames` | When set to `true`, adds an "Upcoming" section below each sport for every followed team whose today slate is either empty or fully final (MLB doubleheaders are handled — both games must be final). Each row shows the team's next scheduled game with its date and start time, pulled once per team from the league's schedule endpoint and cached for 6 hours. Only applies to followed teams (the `teams` array in a sport config) and only for ESPN-backed leagues (MLB, NFL, NBA, NHL, NCAAF, NCAAM, MLS, most soccer). Non-ESPN providers (CPL, PWHL, SNET) are skipped.<br><br>**Type** `Boolean`<br>**Default** `false`

#### Baseball Detail Example

![Baseball Detail](example5.png)

#### Football Detail Examples

Live play — gridiron with ball spot, first-down line, timeouts, and last play:

![Football Detail](footballDetailExample1.png)

Ball sliding to the new spot with a gain trail:

![Football Detail Gain Trail](footballDetailExample2.png)

Red zone — field position, possession indicator, and ball turn red:

![Football Detail Red Zone](footballDetailExample3.png)

Touchdown — the ball slides into the end zone and sits there until the kickoff:

![Football Detail Touchdown](footballDetailExample4.png)

With `colored: false`, the field goes full grayscale like the rest of the display:

![Football Detail Monochrome](footballDetailExample5.png)

#### Score Animation Example

![Score Animation](scoreAnimationsExample.gif)
Expand Down
Binary file added footballDetailExample1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added footballDetailExample2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added footballDetailExample3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added footballDetailExample4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added footballDetailExample5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions node_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ module.exports = NodeHelper.create({

getDirectoryTree(dirPath) {
const result = []
if (!fs.existsSync(dirPath)) {
return result
}
const files = fs.readdirSync(dirPath, { withFileTypes: true })

files.forEach((file) => {
Expand Down Expand Up @@ -79,6 +82,7 @@ module.exports = NodeHelper.create({
sport's index from the config's order
*/

Log.debug(`[MMM-MyScoreboard] GET-SCORES ${payload.league} whichDay=${JSON.stringify(payload.whichDay)} gameDate=${payload.gameDate}`)
var self = this
var provider = this.providers[payload.provider]
var provider2 = this.providers[payload.provider]
Expand All @@ -88,6 +92,7 @@ module.exports = NodeHelper.create({

if (payload.whichDay.today) {
provider.getScores(payload, moment(payload.gameDate), function (scores, sortIdx, noGamesToday) {
Log.debug(`[MMM-MyScoreboard] SCORE-UPDATE ${payload.league} games=${scores.length} noGamesToday=${noGamesToday}`)
self.sendSocketNotification('MMM-MYSCOREBOARD-SCORE-UPDATE', { instanceId: payload.instanceId, index: payload.league, scores: scores, label: payload.label, sortIdx: sortIdx, provider: payload.provider, noGamesToday: noGamesToday })
})
}
Expand Down
64 changes: 64 additions & 0 deletions providers/ESPN.js
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,37 @@ module.exports = {
}
}

// Extract football situation data for in-progress football games
var footballSituation = null
if (gameState === 1 && leaguePath && leaguePath.startsWith('football/')) {
var fbSituation = game.competitions[0].situation
if (fbSituation) {
var possessionSide = null
if (fbSituation.possession != null) {
var possId = String(fbSituation.possession)
var hId = hTeamData.id != null ? String(hTeamData.id) : (hTeamData.team && hTeamData.team.id != null ? String(hTeamData.team.id) : null)
var vId = vTeamData.id != null ? String(vTeamData.id) : (vTeamData.team && vTeamData.team.id != null ? String(vTeamData.team.id) : null)
if (hId !== null && possId === hId) possessionSide = 'home'
else if (vId !== null && possId === vId) possessionSide = 'away'
}
var fieldPos = this.computeFieldPosition(
fbSituation.possessionText, fbSituation.distance, possessionSide,
hTeamData.team && hTeamData.team.abbreviation,
vTeamData.team && vTeamData.team.abbreviation)
footballSituation = {
downDistance: fbSituation.shortDownDistanceText || fbSituation.downDistanceText || '',
possessionText: fbSituation.possessionText || '',
lastPlay: (fbSituation.lastPlay && fbSituation.lastPlay.text) ? fbSituation.lastPlay.text : '',
ballX: fieldPos.ballX,
firstDownX: fieldPos.firstDownX,
possession: possessionSide,
isRedZone: !!fbSituation.isRedZone,
homeTimeouts: (typeof fbSituation.homeTimeouts === 'number') ? fbSituation.homeTimeouts : null,
awayTimeouts: (typeof fbSituation.awayTimeouts === 'number') ? fbSituation.awayTimeouts : null,
}
}
}

if (payload.league !== 'SOCCER_ON_TV' || (broadcast.length > 0)) {
formattedGamesList.push({
classes: classes,
Expand All @@ -1104,6 +1135,7 @@ module.exports = {
vTeamLogoUrl: vTeamData.team.logo ? vTeamData.team.logo : '',
playoffStatus: playoffStatus,
baseballSituation: baseballSituation,
footballSituation: footballSituation,
})
}
})
Expand All @@ -1114,6 +1146,37 @@ module.exports = {
return formattedGamesList
},

/*
Compute gridiron coordinates from ESPN's possessionText ("DAL 22", "50").
x scale: 0 = visitor goal line (left), 100 = home goal line (right).
Abbreviations are trimmed because formatScores may append a trailing
space to disambiguate colliding NCAA abbreviations (e.g. 'SDSU ').
*/
computeFieldPosition: function (possessionText, distance, possession, hAbbr, vAbbr) {
var result = { ballX: null, firstDownX: null }
var text = (possessionText == null ? '' : String(possessionText)).trim()
if (text === '50') {
result.ballX = 50
}
else {
var m = text.match(/^(.*\S)\s+(\d{1,2})$/)
if (!m) return result
var abbr = m[1].trim()
var n = parseInt(m[2], 10)
if (n < 1 || n > 50) return result
if (abbr === (vAbbr || '').trim()) result.ballX = n
else if (abbr === (hAbbr || '').trim()) result.ballX = 100 - n
else return result
}
if (typeof distance === 'number' && distance > 0
&& (possession === 'home' || possession === 'away')) {
var fd = possession === 'home' ? result.ballX - distance : result.ballX + distance
// Goal-to-go lands in the end zone: the goal line itself is the marker
if (fd > 0 && fd < 100) result.firstDownX = fd
}
return result
},

formatT25Ranking: function (rank) {
if (rank >= 1 && rank <= 25) {
return rank
Expand Down Expand Up @@ -1310,6 +1373,7 @@ module.exports = {
vTeamLogoUrl: getLogo(vData.team),
playoffStatus: '',
baseballSituation: null,
footballSituation: null,
gameDate: comp.date,
}
},
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/mm-bootstrap/config-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ module.exports = {
ipWhitelist: [],
language: 'en',
locale: 'en-US',
logLevel: ['INFO', 'LOG', 'WARN', 'ERROR'],
logLevel: ['INFO', 'LOG', 'WARN', 'ERROR', 'DEBUG'],
timeFormat: 12,
units: 'imperial',
modules: [
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/mm-bootstrap/install-mm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function copyModuleSources() {
// tests/e2e/.tmp (recursion!), .git, package-lock.json.
fs.mkdirSync(MODULE_TARGET, { recursive: true })
const SKIP = new Set([
'node_modules', '.git', '.github', 'tests', 'logos_custom',
'node_modules', '.git', '.github', 'tests',
])
function walk(srcDir, dstDir) {
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
Expand Down
22 changes: 21 additions & 1 deletion tests/e2e/mm-bootstrap/start-mm.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,24 @@ async function startMM(opts) {
throw new Error(`MagicMirror did not respond within ${timeoutMs}ms.\nstdout tail:\n${stdoutBuf.slice(-2000)}`)
}

module.exports = { startMM }
/*
Wait until nothing is answering on the port. stop() resolves when the npm
wrapper exits, but the MM server process can linger for a moment — if the
next scenario's readiness probe hits the dying instance it sees the OLD
config and the test runs against the wrong fixture data.
*/
async function waitForPortFree(port, timeoutMs = 10000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(300) })
}
catch {
return // connection refused/timeout — port is free
}
await new Promise(r => setTimeout(r, 200))
}
throw new Error(`Port ${port} still occupied ${timeoutMs}ms after MM stop()`)
}

module.exports = { startMM, waitForPortFree }
11 changes: 8 additions & 3 deletions tests/e2e/scenarios/_index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
/*
Scenario registry. The MATRIX_MODE env var selects which scenarios run:

MATRIX_MODE=curated (default) — 25 hand-picked scenarios covering every
MATRIX_MODE=curated (default) — 30 hand-picked scenarios covering every
view style, key flag, PR #198 upcoming
games, broadcasts, and baseball detail.
games, broadcasts, and baseball/football detail.
MATRIX_MODE=pairwise — algorithmic covering array (~15–25 scenarios).
Every pair of parameter values appears at least
once. Reuses the same NHL fixture.
Expand Down Expand Up @@ -54,9 +54,14 @@ const CURATED = [
require('./broadcast-multiple-networks.js'),
require('./broadcast-showLocalBroadcasts-on.js'),

// G. Baseball detail (2)
// G. Baseball/football detail (7)
require('./baseball-detail-on.js'),
require('./baseball-detail-bases-loaded.js'),
require('./football-detail-on.js'),
require('./football-detail-red-zone.js'),
require('./football-detail-halftime.js'),
require('./football-detail-touchdown.js'),
require('./football-detail-mono.js'),

// H. Multi-sport (1)
require('./multi-nfl-nhl-mlb.js'),
Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/scenarios/football-detail-halftime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict'

const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')

module.exports = {
name: 'football-detail-halftime',
moduleConfig: {
viewStyle: 'largeLogos',
showLeagueSeparators: true,
colored: true,
highlightWinners: true,
showDetail: true,
detailViewOverride: true,
sports: [{ league: 'NFL', teams: ['DAL'] }],
},
fixtures: {
[espnScoreboardRoute('football/nfl')]: {
events: [{
id: 'nfl-live-halftime',
date: todayMidLocalISO(),
status: { type: { id: '23', detail: 'Halftime', shortDetail: 'Halftime', description: 'Halftime' } },
competitions: [{
date: todayMidLocalISO(),
status: { type: { id: '23', detail: 'Halftime', shortDetail: 'Halftime', description: 'Halftime' } },
broadcasts: [],
situation: {
homeTimeouts: 3,
awayTimeouts: 3,
},
competitors: [
{ homeAway: 'home', score: '10', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
{ homeAway: 'away', score: '13', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
],
}],
}],
},
},
assertions: [async (page, expect) => {
await expect(page.locator('.MMM-MyScoreboard .football-field')).toHaveCount(0)
await expect(page.locator('.MMM-MyScoreboard .box-score.football-has-field')).toHaveCount(0)
await expect(page.locator('.MMM-MyScoreboard .football-timeouts')).toHaveCount(2)
}],
}
54 changes: 54 additions & 0 deletions tests/e2e/scenarios/football-detail-mono.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict'

const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')

module.exports = {
name: 'football-detail-mono',
moduleConfig: {
viewStyle: 'largeLogos',
showLeagueSeparators: true,
colored: false,
highlightWinners: true,
showDetail: true,
detailViewOverride: true,
sports: [{ league: 'NFL', teams: ['DAL'] }],
},
fixtures: {
[espnScoreboardRoute('football/nfl')]: {
events: [{
id: 'nfl-live-mono',
date: todayMidLocalISO(),
status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
competitions: [{
date: todayMidLocalISO(),
status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
broadcasts: [],
situation: {
down: 2,
distance: 7,
downDistanceText: '2nd & 7 at DAL 22',
shortDownDistanceText: '2nd & 7',
possessionText: 'DAL 22',
possession: '6',
isRedZone: false,
homeTimeouts: 3,
awayTimeouts: 2,
lastPlay: { id: '1', text: 'D. Prescott pass short right to C. Lamb for 9 yards' },
},
competitors: [
{ homeAway: 'home', score: '14', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
{ homeAway: 'away', score: '10', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
],
}],
}],
},
},
assertions: [async (page, expect) => {
const ball = page.locator('.MMM-MyScoreboard .field-ball')
await expect(ball).toHaveCount(1)
// Full mono: gray ball, gray turf, gray first-down line — no color leak
expect(await ball.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(204, 204, 204)')
expect(await page.locator('.MMM-MyScoreboard .football-field').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(36, 36, 36)')
expect(await page.locator('.MMM-MyScoreboard .field-first-down').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgb(204, 204, 204)')
}],
}
54 changes: 54 additions & 0 deletions tests/e2e/scenarios/football-detail-on.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict'

const { espnScoreboardRoute, todayMidLocalISO, team, TEAMS } = require('./_shared')

module.exports = {
name: 'football-detail-on',
moduleConfig: {
viewStyle: 'largeLogos',
showLeagueSeparators: true,
colored: true,
highlightWinners: true,
showDetail: true,
detailViewOverride: true,
sports: [{ league: 'NFL', teams: ['DAL'] }],
},
fixtures: {
[espnScoreboardRoute('football/nfl')]: {
events: [{
id: 'nfl-live-detail',
date: todayMidLocalISO(),
status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
competitions: [{
date: todayMidLocalISO(),
status: { type: { id: '2', detail: 'In Progress', shortDetail: '2nd 8:42', description: 'In Progress' } },
broadcasts: [],
situation: {
down: 2,
distance: 7,
yardLine: 78,
downDistanceText: '2nd & 7 at DAL 22',
shortDownDistanceText: '2nd & 7',
possessionText: 'DAL 22',
possession: '6',
isRedZone: false,
homeTimeouts: 3,
awayTimeouts: 2,
lastPlay: { id: '1', text: 'D. Prescott pass short right to C. Lamb for 9 yards (Q. Diggs)' },
},
competitors: [
{ homeAway: 'home', score: '14', id: '6', team: { ...team(TEAMS.DAL), id: '6' } },
{ homeAway: 'away', score: '10', id: '21', team: { ...team(TEAMS.PHL), id: '21' } },
],
}],
}],
},
},
assertions: [async (page, expect) => {
const ball = page.locator('.MMM-MyScoreboard .field-ball')
await expect(ball).toHaveCount(1)
expect(await ball.evaluate(el => el.style.getPropertyValue('--ball-x'))).toBe('78%')
await expect(page.locator('.MMM-MyScoreboard .field-first-down')).toHaveCount(1)
await expect(page.locator('.MMM-MyScoreboard .football-last-play')).toHaveCount(0)
}],
}
Loading
Loading