node-red-contrib-awtrix-epl-live 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/icons/icon.svg CHANGED
@@ -1 +1,86 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41.403 41.337" width="48px" height="48px"><path fill="none" d="M-3.299 -3.348H44.701V44.652H-3.299z" opacity=".06"/><path fill="#fff" d="M40.679,4.652H17.351c-0.089-0.131-0.177-0.143-0.251-0.235L17.01,4.302 c-0.084-0.105-0.152-0.283-0.152-0.395l0.014-0.072h0c0.01-0.019,0.021-0.038,0.021-0.038l0.057-0.114l0.026-0.101V3.577 c0-0.042-0.02-0.077-0.039-0.112c0.067-0.045,0.113-0.107,0.117-0.18c0-0.002,0.003-0.085,0.003-0.085l-0.014-0.184 c-0.039-0.247-0.244-0.664-0.631-0.744c-0.171-0.036-0.279-0.006-0.358,0.028c-0.026-0.022-0.084-0.069-0.117-0.096 c0.031-0.034,0.063-0.069,0.07-0.111c0.135-0.757,0.494-1.601,0.601-1.842c0,0,0.023-0.104,0.023-0.104 c0-0.089-0.048-0.174-0.124-0.221c-0.11-0.067-0.255-0.042-0.336,0.057c-0.001,0.001-0.137,0.18-0.213,0.281 c-0.027-0.103-0.08-0.307-0.08-0.307c-0.023-0.091-0.095-0.162-0.187-0.185c-0.092-0.023-0.188,0.005-0.253,0.073 c0.005-0.005,0.005-0.005-0.319,0.337c0,0-0.326,0.344-0.379,0.401c-0.055-0.064-0.717-0.84-0.717-0.84 c-0.049-0.058-0.121-0.091-0.199-0.091c-0.075,0-0.147,0.034-0.196,0.091c0,0-0.662,0.776-0.717,0.84 c-0.058-0.061-0.7-0.739-0.7-0.739c-0.064-0.069-0.162-0.097-0.253-0.073c-0.091,0.023-0.162,0.094-0.186,0.185 c0,0-0.071,0.275-0.08,0.306c-0.066-0.088-0.211-0.279-0.211-0.279c-0.083-0.101-0.228-0.126-0.338-0.059 c-0.076,0.047-0.124,0.132-0.124,0.221l0.021,0.104c0.107,0.24,0.464,1.083,0.602,1.843c0-0.003,0.005,0.01,0.01,0.021 c-0.056,0.029-0.263,0.136-0.263,0.136c-0.05,0.033-0.082,0.085-0.099,0.143c-0.038-0.008-0.101-0.02-0.101-0.02 c-0.315-0.02-0.55,0.104-0.706,0.369c-0.08,0.135-0.132,0.326-0.132,0.487l0.014,0.136c0.026,0.127,0.085,0.196,0.141,0.235 c-0.235,0.29-0.365,0.63-0.474,1.05H0.724c-0.564,0-1.023,0.457-1.023,1.018c0,0.091,0.031,0.245,0.06,0.348 c0,0.001,0.031,0.136,7.944,29.814l0.006,0.016c0.274,0.804,0.879,1.452,1.661,1.778l0.036,0.015c0,0,10.792,3.833,11.293,4.01 c0.5-0.177,11.292-4.01,11.292-4.01l0.036-0.015c0.782-0.326,1.387-0.974,1.661-1.778l0.006-0.016 c7.914-29.678,7.944-29.813,7.945-29.814c0.029-0.103,0.06-0.256,0.06-0.348C41.701,5.109,41.242,4.652,40.679,4.652z"/><path fill="#ed1a3b" d="M18.245,18.651c-0.48-0.026-1.096,0.092-1.447,0.299c-0.002,0.496-0.053,1-0.148,1.435 c0.326,0.239,1.004,0.524,1.388,0.606c0.3-0.219,0.685-0.671,0.892-1.016C18.81,19.482,18.576,18.966,18.245,18.651 M18.002,19.189 c-0.394-0.103-1-0.104-1-0.104c0.339-0.116,0.766-0.211,1.143-0.203c0.237,0.256,0.396,0.609,0.538,1.011 C18.683,19.893,18.336,19.445,18.002,19.189 M18.72,17.892c-0.003-0.002-0.073,0.119-0.412,0.135 c0.041,0.052,0.106,0.169,0.124,0.237c0,0,0.397,0.027,0.655,0.093c0.279,0.249,0.489,0.542,0.638,0.901 c0,0.002-0.173,0.362-0.379,0.716c0.172,0.351,0.367,0.801,0.4,1.211c-0.193,0.495-0.534,0.916-0.969,1.211 c0.059-0.141,0.124-0.333,0.165-0.534c-0.245-0.021-0.675-0.109-0.898-0.182c-0.263,0.325-0.584,0.547-0.991,0.749 c0.059,0.132,0.183,0.32,0.267,0.411c-0.664,0-1.27-0.248-1.73-0.656c0,0,0.331,0.044,0.627-0.012 c-0.068-0.366-0.094-1.003-0.063-1.329c-0.464-0.132-0.884-0.311-1.219-0.519c-0.062,0.122-0.131,0.287-0.173,0.387 c-0.105-0.596-0.012-1.208,0.3-1.747c0.043,0.067,0.085,0.138,0.12,0.199c0.31-0.212,0.736-0.431,1.191-0.576 c0.021-0.183,0.067-0.447,0.105-0.618c0,0-0.172-0.016-0.245-0.092c-0.037-0.036-0.081-0.033-0.113-0.019 c-0.155,0.073-0.383,0.025-0.428,0.003c-0.874,0.514-1.476,1.475-1.476,2.56c0,1.633,1.328,2.956,2.967,2.956 c1.639,0,2.967-1.323,2.967-2.956C20.149,19.348,19.577,18.409,18.72,17.892 M18.477,22.633c-0.017-0.154-0.041-0.341-0.05-0.442 c-0.138-0.122-0.261-0.229-0.363-0.311c0.168,0.049,0.454,0.116,0.644,0.155C18.678,22.167,18.54,22.526,18.477,22.633 M19.652,19.915l0.157-0.348c0,0,0.227,0.748,0.01,1.373C19.819,20.94,19.819,20.416,19.652,19.915"/><path fill="#000043" d="M41.403,5.671c0,0.065-0.009,0.128-0.025,0.187l-7.935,29.804c-0.231,0.766-0.787,1.381-1.527,1.69 l-0.027,0.011l-11.187,3.973L9.515,37.363l-0.027-0.011c-0.74-0.309-1.297-0.925-1.527-1.69L0.025,5.858 C0.009,5.799,0,5.736,0,5.671C0,5.272,0.325,4.95,0.724,4.95h9.23L9.932,5.035C9.818,5.461,9.706,5.831,9.451,6.056H2.425 c-0.485,0-0.879,0.392-0.879,0.876c0,0.077,0.01,0.153,0.029,0.224l7.448,28.189c0.134,0.446,0.465,0.81,0.892,0.987l10.786,3.832 l10.786-3.832c0.427-0.177,0.758-0.541,0.892-0.987l7.448-28.189c0.019-0.071,0.029-0.146,0.029-0.224 c0-0.484-0.393-0.876-0.879-0.876h-21.36c-0.011-0.018,0.011,0.018,0,0c0.017-0.007,0.036-0.013,0.036-0.013 c0.07-0.048,0.079-0.184,0.079-0.27c-0.001-0.318-0.091-0.589-0.207-0.823h23.154C41.077,4.95,41.403,5.272,41.403,5.671z M15.71,0.945c0,0,0.489-0.5,0.634-0.68c-0.234,0.53-0.507,1.3-0.618,1.848c-0.577-0.183-1.298-0.235-1.913-0.235 s-1.335,0.052-1.912,0.235c-0.111-0.548-0.384-1.318-0.619-1.848c0.146,0.18,0.634,0.68,0.634,0.68l0.107-0.835l0.944,0.941 L13.813,0l0.845,1.052l0.945-0.941L15.71,0.945 M12.249,1.766c0.168-0.04,0.374-0.077,0.601-0.102l-0.584-0.494L12.249,1.766 M13.813,1.616c0.179,0,0.353,0.005,0.518,0.014l-0.518-0.595L13.296,1.63C13.461,1.621,13.635,1.616,13.813,1.616 M14.777,1.664 c0.227,0.025,0.432,0.062,0.6,0.102L15.361,1.17L14.777,1.664 M28.994,22.155c0.184,0.238,0.224,0.563,0.186,0.725 c-0.021,0.092-0.026,0.178-0.192,0.178h-1.726c-0.207,0-0.323-0.004-0.373-0.152c-0.063-0.191-0.061-0.51,0.055-0.727 c0.091-0.17,0.187-0.286,0.243-0.46c0.037-0.116,0.057-0.315-0.062-0.601c-0.086-0.206-0.379-0.85-0.618-1.303 c-0.077-0.146-0.145-0.257-0.368-0.479c-1.769-1.764-1.83-2.171-2.02-2.353c-0.179-0.171-0.393-0.039-0.519,0.166 c-0.614,1.001,0.562,2.097,0.732,2.379c0.236,0.391,0.21,0.629-0.006,0.931c-0.331,0.46-0.707,0.94-0.854,1.279 c-0.084,0.195-0.125,0.412-0.219,0.549c-0.124,0.182-0.337,0.295-0.424,0.398c-0.089,0.104-0.17,0.206-0.216,0.262 c-0.065,0.081-0.151,0.112-0.322,0.112h-1.834c-0.156,0-0.163-0.144-0.163-0.144c-0.007-0.071,0.039-0.543,0.391-0.843 c0.217-0.186,0.669-0.179,0.832-0.194c0.338-0.032,0.435-0.097,0.64-0.358c0.233-0.297,0.403-0.894,0.431-1.036 c0.046-0.217,0.008-0.367-0.162-0.493c-0.576-0.429-1.893-1.3-2.285-2.473c-0.05-0.149-0.125-0.266-0.405-0.205 c-0.2,0.043-0.541,0.113-0.887,0.108c-0.11,0-0.132,0.07-0.16,0.131c-0.052,0.109-0.1,0.2-0.25,0.234 c-0.206,0.047-0.732,0.044-0.871,0.003c-0.088-0.026-0.133-0.072-0.168-0.123c-0.045-0.064-0.139-0.075-0.199-0.002 c-0.04,0.049-0.102,0.112-0.281,0.114c-0.154,0.001-0.282-0.006-0.417-0.029c-0.229-0.041-0.163-0.163-0.3-0.175 c-0.091-0.009-0.105,0.083-0.256,0.088c-0.369,0.012-0.431-0.153-0.431-0.153c-1.011,0.576-1.692,1.678-1.692,2.922 c0,0.887,0.359,1.692,0.925,2.291c-0.058,0.23-0.119,0.331-0.303,0.331h-2.535c-0.091,0-0.15-0.035-0.164-0.151 c-0.022-0.184,0.097-0.847,0.646-1.016c0.29-0.089,0.365-0.044,0.638-0.073c0.118-0.013,0.332-0.025,0.383-0.224 c0.155-0.601-0.459-1.323-0.543-3.123c-0.012-0.247-0.022-0.452-0.169-0.744c-0.146-0.291-0.615-0.855-0.723-2.732 c-0.404,0.516-1.103,0.401-1.214,0.341c0.388-0.386,0.958-1.543,0.651-2.724c-0.273,0.557-0.737,0.666-1.049,0.651 c0.517-0.438,0.514-1.54,0.273-1.926c-0.111,0.118-0.214,0.322-0.255,0.502c-0.251-0.41-0.687-1.948-0.054-3.23 c-0.463,0.205-0.72,0.894-0.771,1.072c-0.129-0.441-0.296-1.939,0.679-2.79C9.79,6.93,9.364,6.757,9.263,6.615 c0.456-0.192,0.828-0.667,0.987-1.356c0.166-0.724,0.343-1.557,0.826-1.643c0.135-0.024,0.242,0.009,0.347,0.108 c0.079,0.074,0.141,0.065,0.118-0.056c-0.031-0.167-0.211-0.485-0.465-0.474c-0.164,0.008-0.268,0.133-0.317,0.286 c-0.116-0.208-0.036-0.842,0.483-0.802c0.246,0.018,0.4,0.205,0.453,0.255c0.049,0.05,0.095,0.07,0.084-0.053 c-0.01-0.09-0.076-0.234-0.191-0.341c0.567-0.37,1.778-0.446,2.578-0.419c-0.093,0.308-0.137,0.638-0.115,0.787 c0.2-0.314,0.443-0.573,0.668-0.747c0.32,0.035,0.733,0.127,0.85,0.168c0.279,0.095,0.358,0.209,0.451,0.363 c0.113-0.075,0.261-0.125,0.417-0.095c0.265,0.051,0.414,0.34,0.373,0.737c-0.039-0.059-0.134-0.22-0.321-0.228 c-0.21-0.009-0.382,0.23-0.433,0.403c-0.05,0.165,0.017,0.177,0.101,0.105c0.097-0.084,0.328-0.246,0.575,0.025 c-0.236,0.324-0.075,0.657,0.086,0.879c0.188,0.257,0.663,0.733,0.665,1.409c-0.125-0.047-0.323-0.132-0.51-0.125 c0.311,0.276,0.476,0.524,0.698,1.088c0.182,0.467,0.359,0.784,0.78,1.101c-0.241,0.138-0.649,0.083-0.841,0 c0.798,1.242,1.922,1.371,4.064,2.89c1.852,1.315,3.831,0.675,5.021,2.005c0.855-0.046,1.638-0.574,1.529-1.152 c-0.162-0.857-1.968-0.959-2.793-0.956c-0.789,0.003-3.033,0.058-3.077-1.197c-0.017-0.469,0.352-0.93,1.221-1.22 c0.11-0.218,0.276-0.698,1.082-0.809c-0.048,0.076-0.091,0.185-0.091,0.301c0.604-0.38,1.712-0.248,2.113,0.082 c-0.996,0.081-1.075,1.099-2.037,1.264c-0.454,0.079-0.729-0.136-0.906-0.27c-0.211,0.011-0.717,0.238-0.741,0.557 c-0.056,0.734,1.933,0.641,2.476,0.626c1.814-0.05,3.572,0.312,3.644,1.644c0.046,0.858-0.713,1.641-1.967,1.97 c0.405,0.79,0.405,1.962,0.388,2.563c-0.018,0.708,0.055,1.753,0.13,1.948c0.168,0.439,0.335,0.516,0.408,0.917 c0.124,0.676,0.081,1.315,0.36,2.182C28.599,21.845,28.875,21.998,28.994,22.155 M11.873,4.186c-0.014,0.01-0.02,0.112-0.02,0.112 s0.184,0.05,0.384,0.028c0.23-0.027,0.358-0.099,0.473-0.077c0.195,0.041,0.527,0.285,0.557,0.385 c0.084,0.261,0.028,0.702-0.023,0.935c0.172-0.036,0.39-0.067,0.575-0.068c0.238,0,0.531,0.044,0.711,0.104 c0.006,0.057,0.027,0.143-0.02,0.185c-0.201,0.18-0.547,0.396-0.547,0.396l-0.044,0.352c0.224,0.072,0.507,0.342,0.728,0.352 c0.205,0.01,0.367-0.028,0.464-0.084c0.074-0.042,0.111-0.115,0.104-0.213C15.2,6.381,15.1,6.119,14.984,5.931 c-0.063-0.101-0.09-0.236,0.035-0.347c0.126-0.112,0.326-0.234,0.473-0.305c0.254-0.121,0.327-0.241,0.283-0.392 c-0.064-0.212-0.198-0.453-0.198-0.453c-0.094,0.048-0.185,0.163-0.328,0.259c-0.061,0.041-0.13,0.044-0.198,0.036 c-0.116-0.014-0.199-0.058-0.262-0.053c-0.151,0.009-0.272,0.166-0.334,0.394c-0.014-0.054-0.091-0.221-0.048-0.431 c0.022-0.1,0.265-0.346,0.461-0.385c0.114-0.024,0.243,0.048,0.472,0.075c0.201,0.023,0.337-0.006,0.337-0.006 s-0.029-0.24-0.104-0.449c-0.098-0.269-0.191-0.35-0.396-0.42c-0.225-0.077-0.495-0.157-0.766-0.212 c-0.091,0.057-0.417,0.492-0.595,0.746c-0.178-0.254-0.505-0.689-0.596-0.746c-0.189,0.04-0.472,0.119-0.561,0.151 c-0.127,0.047-0.205,0.095-0.186,0.209c0.044,0.251,0.016,0.322-0.228,0.408C12.059,4.073,11.923,4.154,11.873,4.186 M13.71,7.838 c-0.016-0.08,0.027-0.103,0.087-0.059c0.227,0.17,0.238,0.513,0.241,0.56c0.004,0.047,0.035,0.038,0.065,0.007 c0.145-0.16,0.342-0.26,0.412-0.31c0.164-0.117,0.194-0.274,0.212-0.361c0.02-0.088,0.064-0.193,0.094-0.247 c0.027-0.049,0.019-0.121-0.062-0.148c-0.13-0.043-0.277-0.113-0.468-0.314c-0.048-0.051-0.148-0.089-0.257-0.054 C13.95,6.94,13.89,6.955,13.816,6.955c-0.074,0-0.134-0.015-0.219-0.042c-0.109-0.035-0.209,0.003-0.257,0.054 c-0.192,0.201-0.339,0.277-0.45,0.307C12.801,7.3,12.783,7.34,12.771,7.393c-0.116,0.549,0.254,0.594,0.346,0.63 c0.205,0.076,0.368,0.244,0.441,0.317c0.034,0.034,0.057,0.03,0.076,0C13.737,8.187,13.724,7.915,13.71,7.838 M12.52,6.806 c0.097,0.055,0.258,0.093,0.464,0.083c0.221-0.01,0.503-0.28,0.727-0.352l-0.044-0.352c0,0-0.352-0.222-0.556-0.404 c-0.108-0.096-0.325-0.032-0.349-0.007c-0.158,0.17-0.326,0.536-0.345,0.818C12.409,6.69,12.446,6.764,12.52,6.806 M11.831,5.176 c0.161,0.108,0.467,0.013,0.545-0.007c0.076-0.021,0.258-0.093,0.335-0.093c0.055,0,0.126-0.001,0.205,0.062 c0.031,0.026,0.053,0.025,0.05-0.024c-0.002-0.047-0.005-0.431-0.179-0.443c-0.063-0.005-0.146,0.04-0.263,0.054 c-0.067,0.008-0.135,0.005-0.196-0.036c-0.143-0.095-0.332-0.235-0.426-0.283C11.673,4.902,11.738,5.113,11.831,5.176 M12.466,11.814c0.189-1.241,1.162-1.535,1.253-2.181C13.231,10.223,11.808,10.521,12.466,11.814 M14.371,14.066 c1.549-0.547,0.926-2.944,2.07-3.498c-0.227,0.568-0.207,1.078,0.029,1.487c0.251-1.367,1.399-1.852,0.613-3.664 c0.099,2.25-1.642,1.593-2.091,3.433c-0.716-0.778,0.188-1.674-0.189-2.34c-0.292,1.446-1.573,2.024-1.144,3.685 c0.137-0.581,0.518-1.161,0.912-1.375C14.945,12.922,14.533,13.759,14.371,14.066 M26.931,15.851 c0.152-0.681-0.196-1.821-0.539-2.304c-0.66-0.923-1.538-0.998-2.321-1.141c-1.307-0.235-1.894-0.366-2.944-1.015 c-0.32-0.198-1.522-1.166-2.472-1.063c-0.272,0.029-0.36,0.083-0.481,0.268c-0.171,0.26-0.258,0.778-0.18,1.165 c0.081,0.403,0.372,0.697,0.372,1.213c0,0.389-0.336,0.852-0.291,1.023c0.042,0.17,0.307,0.175,0.344,0.41 c0.049,0.307-0.903,0.536-0.842,0.838c0.032,0.16,0.566,0.12,0.566,0.29s-0.194,0.324-0.454,0.372 c-0.236,0.045-0.946-0.034-1.205,0.017c-0.657,0.128-0.779,0.669-0.829,0.856c-0.089,0.334,0.21,0.335,0.317,0.331 c0.187-0.006,0.225-0.153,0.315-0.212c0.051-0.035,0.092-0.031,0.097,0.014c0.004,0.035,0.001,0.085,0.001,0.141 c0,0.128,0.057,0.165,0.19,0.193c0.183,0.039,0.387,0.023,0.538-0.018c0.132-0.035,0.189-0.129,0.249-0.217 c0.078-0.116,0.139-0.085,0.147,0.004c0.019,0.238,0.094,0.258,0.242,0.279c0.151,0.021,0.359,0.009,0.453-0.019 c0.19-0.055,0.185-0.347,0.298-0.458c0.148-0.143,0.37-0.115,0.584-0.264c0.214-0.148,0.746-0.718,1.076-1.032 c0.216-0.206,0.201-0.403,0.164-0.647c-0.032-0.211-0.219-1.336,0.261-1.663c0.158-0.107,0.294-0.056,0.255,0.095 c-0.145,0.572,0.114,1.044,0.297,1.269c0.296,0.367,1.647-0.044,1.968-0.086c0.321-0.042,0.686-0.045,0.959,0.217 c0.145,0.139,0.329,0.451,0.417,0.614c0.098,0.181,0.208,0.097,0.232-0.072c0.072-0.517-0.034-1.035-0.283-1.586 c-0.062-0.14,0.016-0.274,0.256-0.161c1.589,0.745,1.674,2.207,1.876,2.49C26.673,16.145,26.88,16.084,26.931,15.851 M9.942,24.146 c1.133,0.019,1.764,0.526,1.764,1.426c0,0.89-0.683,1.6-1.802,1.6c-0.082,0-0.178-0.005-0.178-0.005v1.598H8.598 c0.102-0.086,0.238-0.371,0.238-0.666v-3.297c0-0.231-0.082-0.561-0.245-0.657H9.942 M9.863,26.428 c0.546-0.007,0.921-0.307,0.922-0.803c0-0.463-0.266-0.756-0.867-0.756c-0.109,0-0.192-0.002-0.192-0.002v1.557 C9.726,26.425,9.781,26.428,9.863,26.428 M15.364,28.761h-1.1L13.482,27.1c-0.04-0.075-0.081-0.231-0.301-0.231l-0.119-0.001v1.893 h-1.129c0.102-0.085,0.238-0.37,0.238-0.666v-3.297c0-0.23-0.082-0.561-0.247-0.657l1.46-0.001c1.018,0,1.673,0.354,1.673,1.294 c0,0.68-0.397,1.134-0.852,1.281c0,0,0.111,0.058,0.233,0.276L15.364,28.761z M13.267,26.225c0.533,0,0.86-0.273,0.86-0.701 c0-0.443-0.262-0.664-0.832-0.664H13.06v1.365H13.267 M32.873,28.761h-1.099L30.992,27.1c-0.041-0.075-0.082-0.231-0.302-0.231 l-0.119-0.001v1.893h-1.128c0.101-0.085,0.237-0.37,0.237-0.666v-3.297c0-0.23-0.082-0.561-0.245-0.657l1.459-0.001 c1.018,0,1.673,0.354,1.673,1.294c0,0.68-0.397,1.134-0.852,1.281c0,0,0.111,0.058,0.233,0.276L32.873,28.761z M30.778,26.225 c0.532,0,0.86-0.273,0.86-0.701c0-0.443-0.263-0.664-0.834-0.664H30.57v1.365H30.778 M20.075,24.142h-1.117 c0.164,0.097,0.247,0.425,0.247,0.656v3.297c0,0.296-0.136,0.58-0.238,0.666h1.12v-3.097l0.016-0.002 c0.077,0.233,0.175,0.427,0.232,0.515l0.836,1.359h0.42l0.835-1.359c0.069-0.115,0.192-0.32,0.251-0.515h0.01 c0.002-0.003,0,3.103,0,3.103h0.895v-4.62h-0.885l-1.031,1.756c-0.141,0.234-0.224,0.362-0.285,0.52h-0.006 c-0.058-0.154-0.136-0.289-0.272-0.519L20.075,24.142L20.075,24.142z M25.424,24.14l-1.136,0.001 c0.164,0.097,0.246,0.426,0.246,0.657v3.297c0,0.296-0.136,0.581-0.238,0.665h1.128L25.424,24.14z M18.163,28.765l0.316-0.76 h-1.611v-1.256h1.326v-0.74h-1.326v-1.114h1.504v-0.75l-2.643-0.004c0.164,0.096,0.247,0.427,0.247,0.657v3.297 c0,0.296-0.136,0.581-0.238,0.666L18.163,28.765z M28.653,28.765l0.316-0.76h-1.612v-1.256h1.327v-0.74h-1.327v-1.114h1.504v-0.75 l-2.642-0.004c0.164,0.096,0.247,0.427,0.247,0.657v3.297c0,0.296-0.136,0.581-0.238,0.666L28.653,28.765z M15.164,34.3 l0.317-0.759h-1.612v-1.256h1.327v-0.74h-1.327v-1.116h1.505v-0.75l-2.643-0.002c0.163,0.097,0.245,0.426,0.245,0.657v3.297 c0,0.295-0.135,0.581-0.238,0.665L15.164,34.3z M31.369,34.3l0.316-0.759h-1.611v-1.256H31.4v-0.74h-1.326v-1.116h1.504v-0.75 l-2.643-0.002c0.164,0.097,0.246,0.426,0.246,0.657v3.297c0,0.295-0.136,0.581-0.238,0.665L31.369,34.3z M12.176,34.3l0.316-0.759 H10.95v-3.865l-1.139,0.001c0.164,0.097,0.247,0.426,0.247,0.657v3.297c0,0.295-0.135,0.581-0.238,0.665L12.176,34.3z M23.034,33.481c-0.147,0.05-0.374,0.079-0.593,0.079c-0.935,0-1.45-0.572-1.45-1.544c0-1.074,0.638-1.599,1.546-1.599 c0.43,0,0.891,0.139,1.162,0.298l-0.001-0.876c-0.265-0.112-0.677-0.212-1.107-0.212c-1.633,0-2.562,0.949-2.562,2.392 c0,1.401,0.849,2.338,2.31,2.338c0.553,0,1.19-0.16,1.577-0.374l-0.005-1.971h-1.113c0.103,0.086,0.236,0.374,0.236,0.669V33.481 M19.649,33.649c0.102,0.282,0.228,0.555,0.359,0.646h-1.052l-0.304-0.937h-1.619l-0.298,0.939h-1.051 c0.131-0.091,0.255-0.364,0.358-0.646c0.063-0.174,1.351-3.964,1.351-3.964h0.906C18.298,29.687,19.586,33.475,19.649,33.649 M18.435,32.657l-0.4-1.185c-0.082-0.252-0.143-0.483-0.197-0.754h-0.005c-0.05,0.271-0.121,0.503-0.203,0.769l-0.387,1.17H18.435z M24.69,32.687c0,0.927,0.374,1.666,1.75,1.666c1.325,0,1.878-0.729,1.878-1.675v-2.993h-0.865v2.952 c0,0.602-0.282,0.981-0.953,0.981c-0.722,0-0.938-0.382-0.938-0.987v-2.946l-1.103-0.002c0.102,0.085,0.235,0.372,0.235,0.668 L24.69,32.687z"/></svg>
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <svg version="1.1" id="Laag_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4
+ viewBox="0 0 1847.7 1849" style="enable-background:new 0 0 1847.7 1849;" xml:space="preserve">
5
+ <style type="text/css">
6
+ .st0{fill:#3B2050;}
7
+ .st1{fill:#FFFFFF;}
8
+ </style>
9
+ <path class="st0" d="M923.8,0c508.6,0,923.8,415.2,923.8,925.2c1.4,511.3-415.2,923.8-929.2,923.8C413.9,1849,0,1432.4,0,923.8
10
+ S413.9,1.4,923.8,0L923.8,0z M503.2,117.7c-8.1,4.1-13.5,5.4-17.6,8.1c-82.5,51.4-156.9,112.3-219.1,186.7
11
+ C152.8,446.4,86.6,601.9,71.7,777.7c-10.8,121.7-9.5,244.8,23,363.8c146.1,543.7,754.7,802.1,1247.1,530.2c5.4-2.7,9.5-5.4,17.6-9.5
12
+ c-5.4,25.7-10.8,47.3-16.2,71.7c9.5-4.1,13.5-5.4,18.9-8.1c143.4-89.3,257-206.9,331.4-359.8c98.7-202.9,112.3-416.6,66.3-631.7
13
+ c-120.4-555.9-746.6-837.3-1245.7-564c-6.8,4.1-14.9,8.1-25.7,13.5C493.7,159.6,497.8,140.7,503.2,117.7L503.2,117.7z"/>
14
+ <path class="st1" d="M504.5,117.7c-5.4,23-9.5,41.9-14.9,66.3c10.8-5.4,18.9-9.5,25.7-13.5c499.1-273.2,1124,9.5,1245.7,564
15
+ c47.3,216.4,32.5,430.1-66.3,631.7c-74.4,152.8-188,269.2-331.4,359.8c-4.1,2.7-9.5,4.1-18.9,8.1c5.4-25.7,10.8-46,16.2-71.7
16
+ c-8.1,4.1-13.5,6.8-17.6,9.5c-492.3,271.9-1101,13.5-1247.1-530.2c-32.5-120.4-33.8-242.1-23-363.8
17
+ c14.9-175.8,79.8-331.4,194.8-465.3c62.2-74.4,136.6-135.3,219.1-186.7C491,123.1,496.4,121.7,504.5,117.7L504.5,117.7z"/>
18
+ <g>
19
+ <path class="st0" d="M1113.2,1425.6c-6.8-23-12.2-41.9-16.2-58.2c-46,18.9-87.9,40.6-132.6,54.1c-81.2,24.3-161,18.9-238.1-16.2
20
+ c-16.2-6.8-20.3-16.2-17.6-33.8c16.2-78.5,23-155.5,2.7-234c-1.4-4.1-2.7-9.5-4.1-13.5c0-1.4-2.7-2.7-5.4-8.1
21
+ c-40.6,59.5-83.9,115-142,161c-46-100.1-46-202.9-32.5-311.1c-28.4,8.1-54.1,16.2-83.9,24.3c10.8-94.7,46-174.5,101.4-250.2
22
+ c-17.6-4.1-32.5-6.8-50-9.5c35.2-71.7,83.9-129.8,150.1-179.9c-10.8,51.4-8.1,94.7,36.5,125.8c-4.1-14.9-8.1-29.8-10.8-46
23
+ c-1.4-12.2-1.4-24.3,0-35.2c8.1-43.3,39.2-60.9,81.2-48.7c8.1,2.7,16.2,5.4,27.1,10.8c-12.2-29.8-32.5-41.9-56.8-52.8
24
+ c0-2.7,0-4.1-1.4-6.8c39.2-12.2,77.1-27.1,117.7-36.5c35.2-8.1,73-8.1,109.6-12.2c35.2-4.1,62.2,9.5,85.2,35.2
25
+ c8.1,8.1,16.2,16.2,25.7,24.3c1.4-1.4,4.1-1.4,5.4-2.7c-2.7-13.5-5.4-25.7-8.1-40.6c4.1,0,8.1,0,12.2,1.4
26
+ c41.9,12.2,82.5,28.4,116.3,56.8c5.4,4.1,13.5,9.5,14.9,16.2c8.1,27.1,14.9,55.5,23,81.2c-39.2-27.1-79.8-55.5-123.1-85.2
27
+ c-6.8,20.3-10.8,35.2-16.2,51.4c-31.1-18.9-58.2-36.5-86.6-51.4c-10.8-5.4-25.7-8.1-37.9-5.4c-47.3,8.1-92,25.7-132.6,59.5
28
+ c6.8,5.4,13.5,9.5,24.3,17.6c-29.8,14.9-55.5,28.4-75.7,58.2c35.2-5.4,39.2,17.6,50,37.9c17.6,35.2,48.7,52.8,87.9,54.1
29
+ c47.3,1.4,94.7,0,143.4,0c-13.5-14.9-29.8-29.8-44.6-46c-16.2-18.9-36.5-25.7-60.9-23c-33.8,2.7-66.3-5.4-93.3-27.1
30
+ c37.9-44.6,73-46,148.8-5.4c12.2-6.8,24.3-12.2,36.5-18.9c1.4,4.1,2.7,5.4,2.7,5.4c-16.2,33.8-16.2,33.8,10.8,60.9
31
+ c16.2,16.2,31.1,33.8,46,50c20.3-4.1,40.6-9.5,60.9-9.5c20.3-1.4,40.6,2.7,63.6,4.1c-29.8-32.5-59.5-62.2-89.3-93.3
32
+ c1.4-2.7,4.1-5.4,5.4-8.1c10.8,6.8,21.6,12.2,33.8,18.9c13.5-2.7,21.6-29.8,40.6-10.8c13.5,13.5,12.2,27.1-1.4,44.6
33
+ c10.8,12.2,23,23,35.2,37.9c2.7-9.5,4.1-13.5,6.8-18.9h4.1c97.4,138,125.8,286.8,70.3,453.1c-13.5-14.9-25.7-27.1-40.6-41.9
34
+ c-9.5,63.6-25.7,119-56.8,169.1C1205.2,1349.9,1164.6,1391.8,1113.2,1425.6L1113.2,1425.6z M963,869.7c16.2,31.1,32.5,59.5,46,87.9
35
+ c6.8,14.9,16.2,17.6,29.8,13.5c47.3-12.2,83.9-41.9,121.7-71.7c1.4-1.4,4.1-4.1,4.1-6.8c0-20.3,2.7-40.6-1.4-59.5
36
+ c-1.4-8.1-18.9-12.2-28.4-18.9c-6.8-4.1-12.2-9.5-18.9-10.8c-9.5-2.7-20.3-6.8-29.8-4.1C1040.1,812.9,1002.3,840,963,869.7
37
+ L963,869.7z M1048.3,1032c5.4,12.2,14.9,27.1,21.6,43.3c5.4,12.2,12.2,14.9,23,9.5c20.3-10.8,40.6-20.3,63.6-31.1l8.1,64.9
38
+ c2.7,0,5.4,0,6.8-1.4c31.1-24.3,51.4-56.8,54.1-94.7c1.4-17.6-10.8-43.3-25.7-52.8c-12.2-8.1-39.2-4.1-54.1,4.1
39
+ C1113.2,987.4,1082.1,1010.4,1048.3,1032L1048.3,1032z M1249.8,799.4c-14.9,9.5-25.7,17.6-37.9,25.7c-4.1,2.7-8.1,8.1-8.1,12.2
40
+ c-12.2,52.8,18.9,86.6,51.4,123.1C1276.9,906.2,1270.1,853.5,1249.8,799.4z"/>
41
+ <path class="st0" d="M1030.7,263.8c2.7,40.6,5.4,75.7,9.5,115c23-25.7,41.9-46,62.2-67.6l4.1,4.1c-8.1,39.2-17.6,79.8-27.1,120.4
42
+ c-135.3-33.8-265.1-18.9-389.5,47.3c-17.6-39.2-36.5-75.7-56.8-120.4c28.4,17.6,52.8,33.8,81.2,51.4c-5.4-37.9-10.8-73-17.6-112.3
43
+ c43.3,29.8,81.2,55.5,121.7,83.9c13.5-40.6,27.1-78.5,41.9-121.7c24.3,37.9,46,71.7,69,109.6C964.4,336.8,995.5,303,1030.7,263.8
44
+ L1030.7,263.8z M196.1,1175.4c-2.7-8.1-5.4-13.5-8.1-20.3c25.7-10.8,50-20.3,77.1-31.1c8.1,21.6,16.2,43.3,25.7,67.6
45
+ c4.1,8.1,12.2,18.9,13.5,31.1c1.4,17.6-14.9,23-28.4,28.4s-25.7,10.8-40.6,16.2c-2.7-6.8-5.4-12.2-9.5-20.3
46
+ c14.9-6.8,29.8-10.8,43.3-18.9c5.4-2.7,8.1-12.2,10.8-17.6c-6.8-2.7-14.9-8.1-21.6-6.8c-13.5,2.7-27.1,9.5-43.3,16.2
47
+ c-2.7-5.4-5.4-12.2-9.5-18.9c14.9-6.8,29.8-10.8,41.9-18.9c5.4-4.1,8.1-12.2,10.8-18.9c-6.8-1.4-14.9-6.8-21.6-5.4
48
+ C225.9,1163.2,212.4,1170,196.1,1175.4L196.1,1175.4z M486.9,1391.8c6.8,5.4,12.2,9.5,18.9,14.9c-21.6,27.1-41.9,52.8-64.9,81.2
49
+ c13.5,10.8,25.7,21.6,40.6,33.8c-4.1,5.4-8.1,10.8-13.5,17.6l-60.9-48.7C434.2,1458.1,459.9,1425.6,486.9,1391.8z M848.1,1601.5
50
+ c16.2-2.7,23,2.7,18.9,17.6l-4.1,28.4c-4.1,24.3-21.6,39.2-44.6,35.2c-24.3-2.7-37.9-18.9-36.5-44.6c1.4-14.9,4.1-29.8,5.4-47.3
51
+ c8.1,1.4,13.5,2.7,21.6,4.1c-2.7,14.9-5.4,29.8-5.4,43.3c0,8.1,6.8,18.9,13.5,21.6c10.8,5.4,18.9-4.1,21.6-14.9
52
+ C842.7,1631.2,845.4,1616.4,848.1,1601.5L848.1,1601.5z M227.2,1029.3c-12.2-44.6-43.3-14.9-67.6-23c0-4.1-1.4-10.8-2.7-18.9
53
+ c28.4-2.7,55.5-6.8,81.2-9.5c2.7,17.6,4.1,31.1,5.4,47.3C239.4,1026.6,234,1026.6,227.2,1029.3L227.2,1029.3z M339.5,1420.2
54
+ c-4.1-4.1-6.8-9.5-12.2-14.9l60.9-52.8c10.8,12.2,21.6,24.3,33.8,37.9c-2.7,2.7-8.1,6.8-12.2,10.8
55
+ C374.7,1370.2,363.8,1413.5,339.5,1420.2z M258.3,1306.6c-4.1-6.8-6.8-12.2-10.8-18.9c24.3-13.5,47.3-27.1,73-40.6
56
+ c4.1,6.8,6.8,12.2,12.2,18.9C307,1279.6,282.7,1291.7,258.3,1306.6z M349,1222.7c6.8,9.5,13.5,14.9,12.2,20.3
57
+ c-1.4,5.4-8.1,9.5-12.2,13.5c-4.1-4.1-12.2-9.5-13.5-13.5C334.1,1237.6,342.2,1232.2,349,1222.7z"/>
58
+ </g>
59
+ <path class="st1" d="M963,869.7c39.2-29.8,77.1-56.8,123.1-69c9.5-2.7,20.3,1.4,29.8,4.1c6.8,2.7,12.2,6.8,18.9,10.8
60
+ c9.5,5.4,27.1,10.8,28.4,18.9c4.1,18.9,1.4,39.2,1.4,59.5c0,2.7-2.7,5.4-4.1,6.8c-36.5,29.8-73,59.5-121.7,71.7
61
+ c-14.9,4.1-23,0-29.8-13.5C995.5,929.2,979.3,900.8,963,869.7L963,869.7z M1048.3,1032c33.8-21.6,64.9-43.3,97.4-60.9
62
+ c16.2-8.1,41.9-12.2,54.1-4.1c14.9,9.5,27.1,35.2,25.7,52.8c-2.7,37.9-23,70.3-54.1,94.7c-1.4,1.4-4.1,1.4-6.8,1.4l-8.1-64.9
63
+ c-23,10.8-43.3,21.6-63.6,31.1c-10.8,5.4-17.6,2.7-23-9.5C1063.1,1059.1,1055,1042.9,1048.3,1032L1048.3,1032z M1249.8,799.4
64
+ c20.3,54.1,28.4,105.5,5.4,162.3c-32.5-36.5-63.6-71.7-51.4-123.1c1.4-4.1,5.4-9.5,8.1-12.2C1222.7,817,1234.9,810.2,1249.8,799.4z"
65
+ />
66
+ <path class="st0" d="M746.6,1583.9c-16.2-13.5-32.5-13.5-50-2.7c-16.2,9.5-23,31.1-17.6,51.4c5.4,17.6,20.3,28.4,40.6,28.4h10.8
67
+ c-10.8,23-24.3,23-54.1,0c-4.1,4.1-8.1,6.8-13.5,12.2c25.7,35.2,71.7,37.9,85.2,5.4c12.2-28.4,17.6-58.2,27.1-87.9
68
+ C764.2,1587.9,753.4,1589.3,746.6,1583.9z M749.3,1623.1c-9.5,5.4-17.6,14.9-27.1,16.2c-13.5,1.4-21.6-9.5-20.3-23
69
+ c1.4-13.5,9.5-23,21.6-20.3c9.5,2.7,17.6,12.2,25.7,17.6V1623.1z M281.3,879.2H155.5v21.6c13.5,0,27.1,1.4,40.6,1.4
70
+ c0,9.5,0,17.6,1.4,24.3c2.7,23,18.9,37.9,41.9,37.9c21.6,0,39.2-13.5,41.9-35.2C282.7,913,281.3,896.8,281.3,879.2L281.3,879.2z
71
+ M250.2,936c-2.7,2.7-17.6,4.1-18.9,0c-6.8-8.1-9.5-20.3-14.9-33.8h46C257,915.7,255.6,927.9,250.2,936z M652,1550.1
72
+ c-13.5-12.2-28.4-14.9-44.6-9.5c-17.6,6.8-27.1,21.6-28.4,40.6c0,16.2,6.8,32.5,23,39.2c14.9,6.8,29.8,10.8,47.3,16.2
73
+ c10.8-23,21.6-47.3,32.5-71.7C670.9,1558.2,660.1,1556.8,652,1550.1L652,1550.1z M626.3,1602.8c-8.1,0-20.3-10.8-21.6-18.9
74
+ c-2.7-12.2,6.8-24.3,20.3-23c9.5,1.4,17.6,9.5,27.1,14.9v9.5C642.5,1592,634.4,1602.8,626.3,1602.8L626.3,1602.8z M568.1,1562.2
75
+ c16.2-20.3,16.2-40.6,1.4-56.8c-14.9-16.2-40.6-18.9-58.2-5.4c-17.6,13.5-23,39.2-12.2,56.8c12.2,18.9,35.2,28.4,59.5,16.2
76
+ c-8.1-5.4-13.5-10.8-20.3-13.5c-13.5-4.1-24.3-10.8-21.6-29.8C534.3,1543.3,550.5,1552.8,568.1,1562.2z M560,1539.3
77
+ c-13.5-8.1-23-14.9-33.8-21.6C546.4,1508.1,555.9,1513.6,560,1539.3z M934.6,1666.4c-14.9,8.1-24.3,2.7-31.1-13.5H963
78
+ c0-29.8-10.8-46-33.8-50c-18.9-4.1-39.2,6.8-47.3,25.7c-8.1,20.3-1.4,43.3,14.9,55.5c17.6,12.2,43.3,8.1,59.5-8.1
79
+ C950.9,1663.7,945.5,1661,934.6,1666.4L934.6,1666.4z M940.1,1635.3h-36.5C915.7,1616.4,923.8,1616.4,940.1,1635.3z M365.2,1316.1
80
+ c-9.5-20.3-29.8-29.8-51.4-24.3c-20.3,5.4-35.2,24.3-32.5,46c1.4,21.6,17.6,39.2,47.3,40.6c-5.4-9.5-6.8-16.2-12.2-20.3
81
+ c-14.9-12.2-17.6-18.9-8.1-35.2c12.2,16.2,23,31.1,35.2,47.3C367.9,1355.3,374.7,1336.4,365.2,1316.1L365.2,1316.1z M340.9,1341.8
82
+ c-6.8-9.5-12.2-18.9-18.9-27.1c1.4-1.4,2.7-2.7,4.1-5.4c6.8,4.1,14.9,6.8,18.9,12.2c4.1,5.4,2.7,13.5,4.1,20.3H340.9z M258.3,1082.1
83
+ c-1.4-20.3-14.9-36.5-35.2-40.6c-21.6-4.1-41.9,6.8-50,24.3c-9.5,21.6-1.4,44.6,21.6,59.5c2.7-4.1,6.8-9.5,9.5-13.5
84
+ c-10.8-14.9-21.6-28.4-2.7-46c5.4,21.6,10.8,39.2,16.2,59.5C247.5,1118.6,259.7,1105.1,258.3,1082.1z M227.2,1099.7
85
+ c-2.7-13.5-5.4-24.3-8.1-37.9C242.1,1072.6,243.5,1083.4,227.2,1099.7z"/>
86
+ </svg>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-awtrix-epl-live",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "awtrix live football",
5
5
  "author": "evans.l",
6
6
  "license": "MIT",
package/subflow.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "8f04e1df3ff1c424",
3
3
  "type": "subflow",
4
- "name": "EPL Live v0.4.4",
5
- "info": "# EPL Match Tracker\r\n\r\n## Data source\r\n\r\nAs of v0.3.0, this subflow no longer calls ESPN's API directly. Requests go\r\nthrough a free tier Oracle VPS used as a caching relay. This polls ESPN as\r\na single instance, caches the result, and serves the same data back in\r\nESPN's original response shape to each requesting client.\r\n\r\nThe relay only sees requests for team and fixture data - no personal\r\ninformation, no team configuration, nothing beyond what's needed to serve\r\nthe request. A count of unique requesting IP addresses is used to gauge\r\naggregate usage and help stay within free tier limits. No logs or\r\nindividual IP addresses are stored or retained.\r\n\r\n## Basic Usage\r\n\r\n![Basic usage example](https://unpkg.com/node-red-contrib-awtrix-epl-live/images/awtrix-epl.png)\r\n\r\n\r\n## Dependencies\r\n\r\n**Before wiring output 2 or output 3 to an AWTRIX `mqtt out` node**, pre-load\r\nany icons to your Awtrix display(s) (see **Configuration** below).\r\n\r\ndefault is no icon\r\n\r\n## Configuration\r\n\r\nDouble-click the node and set:\r\n\r\n- **Tracked teams** - a JSON array of team names,\r\n matching ESPN's `displayName` exactly (case-insensitive).\r\n\r\n ```json\r\n [\"Liverpool\", \"Manchester City\", \"Chelsea\"]\r\n ```\r\n\r\n If a name doesn't match anything ESPN returns for the EPL,\r\n a warning is logged to the debug sidebar and that team is skipped -\r\n check spelling against ESPN's own team names if a team isn't being\r\n tracked as expected.\r\n Examples of _valid_ teams names that are normally abbreiviated::\r\n> ```Wolverhampton Wanderers, Tottenham Hotspur, Brighton & Hove Albion```\r\n\r\n- **Goals icon** (`goalsIcon`) - icon ID used for goal, penalty goal, own\r\n goal, and disallowed goal notifications. `0` (default) means no icon is\r\n sent.\r\n\r\n- **Red card icon** (`redCardIcon`) - icon ID used for red card\r\n notifications. `0` (default) means no icon is sent.\r\n\r\n- **Full time icon** (`fullTimeIcon`) - icon ID used for full time\r\n notifications. `0` (default) means no icon is sent.\r\n\r\n\r\n- **Recommended LaMetric Icon Numbers**\r\n\r\n | Icon ID | Used for |\r\n |---------|-----------|\r\n | `2965` | goal / penalty goal / own goal, disallowed goal & full time |\r\n | `43844` | red card |\r\n\r\n\r\n- **Minimum brightness**\r\n Your Awtrix device brightness setting has a direct effect on how easy certain\r\n text colours are to read.\r\n The ``__minimum brightness__`` is the perceived brightness used to determine if a\r\n team colour can be used as-is. Colours that evaluate below this value fall\r\n back to the team's alternate colour, and finally to white if both primary and\r\n alternate team colours fail the test.\r\n The default for this setting is `30`, if you still find text colours difficult\r\n to read at this setting then increase it.\r\n\r\n## Outputs\r\n\r\nThis node has three outputs, wire your own downstream nodes to these\r\n\r\n**Output pin #1 - Payload event type, wire to anything you want (eg. Alexa/Google notification)**\r\n\r\n`msg.payload` is one of: `\"goal\"`, `\"disallowed goal\"`, `\"red card\"`,\r\nor `\"full time\"`. Penalties and own goals are also reported as `\"goal\"`\r\non this output. The distinction between `goal` events is only reported\r\nby pin #2 & pin #3..\r\n\r\nWire a `switch` node off this output to route each event type to your\r\nown desired trigger.\r\n\r\nHere is an **Alexa Ready** audio clip you can use for **\"GOAL\"** events to get you started\r\n> `\"<audio src='https://nr-alexa-sounds.s3.eu-west-2.amazonaws.com/goal.mp3'/>\"`\r\n\r\n**Output pin #2 - AWTRIX 3 payload**\r\n\r\n`msg.payload` is a ready-to-send AWTRIX 3 notification object. This output\r\nfires twice per event - once with a short interrupt event notification,\r\nfollowed by a continuation card carrying the fuller detail (score, player,\r\nor team names). Wire this pin straight to one or more `mqtt out` nodes\r\npointed at your AWTRIX 3 device(s).\r\n\r\n**Output pin #3 - AWTRIX NG payload**\r\n\r\n`msg.payload` The same as pin #2 but formatted for AWTRIX-NG devices.\r\n\r\n\r\n## internally\r\n\r\n- **Tracked team colours**\r\n\r\n Primary & secondary team colours are assessed independently.\r\n Home team is assessed first:\r\n If the primary colour is deemed too dark or not viable for\r\n AWTRIX (see **Minimum brightness** above), the alternate is\r\n used, if that fails the fallback is to default to `#FFFFFF`.\r\n Away team colours:\r\n Also assessed in the same manner and also checked against\r\n the home teams resolved colour to avoid the two clashing.\r\n\r\n- **Node Status**\r\n\r\n The node will show any upcoming matches for tracked teams:\r\n\r\n - **\"nothing upcoming\"** - none of your tracked teams have any immediate matches.\r\n - **\"next match xxxx-xx-xx\"** - date of the next match from your tracked teams.\r\n - **\"kick off shortly\"** - a match is about to begin.\r\n - **\"live - in play\"** - match underway and actively polling for live data.\r\n<br><br>\r\n- **Block event dispatch queue**\r\n\r\n block delivery of payloads, designed so multiple\r\n events don't overwrite each other.\r\n\r\n finished matches are removed 60+ minutes after fulltime\r\n\r\n## Known limitations\r\n\r\nTBA",
4
+ "name": "EPL Live v0.5.1",
5
+ "info": "# EPL Match Tracker\r\n\r\n## Basic Usage\r\n\r\n![Basic usage example](https://unpkg.com/node-red-contrib-awtrix-epl-live/images/awtrix-epl.png)\r\n\r\n\r\n## Dependencies\r\n\r\n**Before wiring output 2 or output 3 to an AWTRIX `mqtt out` node**, pre-load\r\nany icons to your Awtrix display(s) (see **Configuration** below).\r\n\r\ndefault is no icon\r\n\r\n## Configuration\r\n\r\nDouble-click the node and set:\r\n\r\n- **Tracked teams**\r\n A JSON array of team names (case-insensitive).\r\n```json\r\n [\"Liverpool\", \"Manchester City\", \"Crystal Palace\", \"Aston Villa\"]\r\n```\r\n\r\n Team names have been relaxed and more common names are also accepted\r\n```json\r\n [\"lfc\", \"man city\", \"palace\", \"villa\"]\r\n```\r\n\r\n* **Additional competitions**<br>\r\n True / False in the dialog options will include or exclude team tracking\r\n for UEFA competitions.\r\n\r\n * `Champions League`.\r\n * `Europa League`.\r\n * `Europa Conference League`.\r\n\r\n- **Goals icon** (`goalsIcon`) - icon ID used for goal, penalty goal, own\r\n goal, and disallowed goal notifications. `0` (default) means no icon is\r\n sent.\r\n\r\n- **Red card icon** (`redCardIcon`) - icon ID used for red card\r\n notifications. `0` (default) means no icon is sent.\r\n\r\n- **Full time icon** (`fullTimeIcon`) - icon ID used for full time\r\n notifications. `0` (default) means no icon is sent.\r\n\r\n\r\n- **Recommended LaMetric Icon Numbers**\r\n\r\n | Icon ID | Used for |\r\n |---------|-----------|\r\n | `2965` | goal / penalty goal / own goal, disallowed goal & full time |\r\n | `43844` | red card |\r\n\r\n\r\n- **Minimum brightness**<br>\r\n Your Awtrix device brightness has a direct effect on how readable certain text colours are and darker palette colours can be difficult to view on the display.<br>\r\n The `minimum brightness` dialogue setting is a 'perceived colour' brightness value used to determine if a team colour will be readable on your Awtrix display.<br>\r\n All team name colours are evaluated against this value, primary and alternate colours are tested in order, if they fall below this threshold and fail the test the team name colour falls back to '#FFFFFF'.<br>\r\n The default value for this setting is `30`. If you find text colours difficult to read or too many team names displayed in white, increase this number.\r\n\r\n## Outputs\r\n\r\nThis node has three outputs, wire your own downstream nodes to these\r\n\r\n**Output pin #1 - Payload event type, wire to anything you want (eg. Alexa/Google notification)**\r\n\r\n`msg.payload` is one of: `\"goal\"`, `\"disallowed goal\"`, `\"red card\"`,\r\nor `\"full time\"`. Penalties and own goals are also reported as `\"goal\"`\r\non this output. The distinction between `goal` events is only reported\r\nby pin #2 & pin #3.\r\n\r\nWire a `switch` node off this output to route each event type to your\r\nown desired trigger.\r\n\r\nHere is an **Alexa Ready** audio clip you can use for **\"GOAL\"** events to get you started<br>\r\n```\"<audio src='https://nr-alexa-sounds.s3.eu-west-2.amazonaws.com/goal.mp3'/>\"```\r\n\r\n\r\n**Output pin #2 - AWTRIX 3 payload**\r\n\r\n`msg.payload` is a ready-to-send 2 part AWTRIX 3 notification object set to fire with\r\n**pin #1** and carries the event plus a followup detail (incl. score, player, team names).\r\nWire this pin straight to one or more `mqtt out` nodes pointed at your AWTRIX 3 device(s).\r\n\r\n**Output pin #3 - AWTRIX NG payload**\r\n\r\n`msg.payload` The same as pin #2 but formatted for AWTRIX-NG devices.\r\n\r\n\r\n## internally\r\n\r\n- **Tracked team colours**<br>\r\n Primary & secondary team colours are assessed independently.<br>\r\n Home team first to determine if the primary colour is deemed too dark or not viable for AWTRIX (see **Minimum brightness**).<br>\r\n Away team colours are assessed in the same manner and checked against the home team resolved colour to avoid the two clashing.\r\n\r\n- **Node Status**<br>\r\n The node will show any upcoming matches for tracked teams only:\r\n\r\n - **\"nothing upcoming\"** - none of your tracked teams have any immediate matches.\r\n - **\"next match xxxx-xx-xx\"** - date of the next match from your tracked teams list.\r\n - **\"kick off shortly\"** - a match is about to begin.\r\n - **\"live - in play\"** - match underway and actively polling for live data.\r\n\r\n## Known limitations\r\n\r\n**Currently** team names are *ONLY* resolved against the Premier League.\r\nNon EPL teams cannot currently be added to Tracked Teams by name.",
6
6
  "category": "",
7
7
  "in": [],
8
8
  "out": [
@@ -185,7 +185,7 @@
185
185
  "meta": {
186
186
  "module": "node-red-contrib-awtrix-epl-live",
187
187
  "type": "epl-live",
188
- "version": "0.4.4",
188
+ "version": "0.5.1",
189
189
  "author": "evans.lee",
190
190
  "desc": "espn epl live football for awtrix",
191
191
  "license": "MIT"
@@ -490,7 +490,7 @@
490
490
  "type": "function",
491
491
  "z": "8f04e1df3ff1c424",
492
492
  "name": "team|score|player(new)",
493
- "func": "// -----------------------------------------------------------------------------\n// Team / score continuation card (pin3 of \"queue dispatcher\").\n//\n// Builds a different AWTRIX message depending on the event type carried\n// on msg.eventType. Note this is the REAL eventType (never folded to\n// \"goal\" the way pin1/pin2 are in queue dispatcher's buildBlock) - this\n// is the only place penalty goal / own goal actually look different\n// from a normal goal.\n//\n// - goal: team + score, with the home/away \"*\" marker,\n// shirt number + surname in gold\n// - penalty goal: same as goal, plus a \"(PEN)\" tag after the surname\n// - own goal: surname + \"(OPPONENT SHORT)(OG)\" in red, then the\n// scoring team + score - deliberately doesn't use\n// the shirt-number layout since it's about the\n// opposing player, not the beneficiary team's squad\n// - disallowed goal: team + score (marker included), phrased to make\n// clear the goal did not count\n// - red card: team + player name - no scoreline, since a red\n// card doesn't change the score\n// - full time: detailed FT scoreline (team names, colours,\n// score) - moved here from queue dispatcher's pin2,\n// which now just sends a quick \"Full Time\" gold\n// flash card instead. Brings full time in line with\n// every other event type (short pin2 interrupt +\n// detailed pin3 continuation). Reads straight off\n// msg.payload (the raw eventData object) rather\n// than the flattened fields below, since those\n// don't carry homeTeam/awayTeam/homeScore/\n// awayScore/homeTeamColor/awayTeamColor.\n//\n// msg.time / msg.team / msg.score / msg.color / msg.player /\n// msg.playerSurname / msg.shirtNum / msg.teamId / msg.homeTeamId /\n// msg.awayTeamId / msg.homeTeamShort / msg.awayTeamShort / msg.eventType\n// are all flattened top-level fields set upstream by \"queue dispatcher\"\n// (baseMsg in buildBlock). msg.payload carries the raw eventData object\n// on every event type - only the full time branch below uses it.\n// -----------------------------------------------------------------------------\n\n// Flattened fields from queue dispatcher's baseMsg - not every event\n// type below uses every field (e.g. shirtNumber only matters for\n// goal/penalty goal/red card; homeTeamId/awayTeamId/homeTeamShort/\n// awayTeamShort only matter for own goal, to work out the OPPONENT's\n// short name).\nconst time = (msg.time || \"\") + \" \";\nconst team = msg.team || \"\";\nconst score = msg.score || \"\";\nconst color = \"#\" + (msg.color || \"FFFFFF\");\nconst player = msg.player || \"\";\nconst playerSurname = msg.playerSurname || \"\";\nconst shirtNumber = msg.shirtNum || \"\";\nconst teamId = msg.teamId || \"\";\nconst homeTeamId = msg.homeTeamId || \"\";\nconst awayTeamId = msg.awayTeamId || \"\";\nconst homeTeamShort = msg.homeTeamShort || \"\";\nconst awayTeamShort = msg.awayTeamShort || \"\";\nconst eventType = msg.eventType || \"\";\n// Icon IDs are configurable per subflow instance via Environment\n// Variables (goalsIcon / redCardIcon / fullTimeIcon) - see queue\n// dispatcher's header for why. 0 means \"no icon\" - the icon key is\n// omitted from the final payload entirely rather than sending a\n// meaningless id. \"Goals\" covers goal, penalty goal, own goal, AND\n// disallowed goal - they all share the same icon setting.\nconst goalsIcon = Number(env.get(\"goalsIcon\")) || 0;\nconst redCardIcon = Number(env.get(\"redCardIcon\")) || 0;\nconst fullTimeIcon = Number(env.get(\"fullTimeIcon\")) || 0;\n\nlet icon = goalsIcon;\nlet timeColor = \"#3BB143\";\nlet textCase = 1;\n\nlet u_team = team.toUpperCase();\nlet u_player = player.toUpperCase();\nlet u_surname = playerSurname.toUpperCase();\n\n\n\n\nlet text;\n\nif (eventType === \"full time\") {\n // Detailed FT scoreline - moved here from queue dispatcher's pin2,\n // exact same fields/formatting as before, just relocated. msg.payload\n // here is the raw eventData object (set by queue dispatcher's\n // buildBlock), not the flattened baseMsg fields used everywhere else\n // in this function - that's why this branch reads from eventData\n // directly instead of the const declarations above.\n const eventData = msg.payload || {};\n const homeTeam = eventData.homeTeam || \"\";\n const awayTeam = eventData.awayTeam || \"\";\n const homeScore = eventData.homeScore;\n const awayScore = eventData.awayScore;\n const homeTeamColor = \"#\" + (eventData.homeTeamColor || \"FFFFFF\");\n const awayTeamColor = \"#\" + (eventData.awayTeamColor || \"FFFFFF\");\n\n // Early return rather than falling through to the shared return at\n // the bottom - this card needs repeat:2 and pushIcon:2, which differ\n // from every other event type's defaults (repeat:1, no pushIcon).\n const fullTimePayload = {\n scrollSpeed: 70,\n pushIcon: 2,\n textCase: 1,\n repeat: 2,\n stack: true,\n text: [\n { t: \"FT \", c: \"#FFD700\" },\n { t: \"| \", c: \"#ffffff\" },\n { t: homeTeam+\" \", c: homeTeamColor },\n { t: homeScore+\" - \"+awayScore+\" \", c: \"#87ae73\" },\n { t: awayTeam, c: awayTeamColor }\n ],\n };\n if (fullTimeIcon !== 0) {\n fullTimePayload.icon = fullTimeIcon;\n }\n return { payload: fullTimePayload };\n\n} else if (eventType === \"red card\") {\n icon = redCardIcon;\n // textCase 2 preserves exact written case - the \"No.\" shirt-number\n // literal below would otherwise get mangled by AWTRIX's textCase:1\n // auto-uppercase. u_team/u_player are pre-uppercased manually\n // instead, to get the \"caps\" look without relying on textCase:1.\n textCase = 2;\n text = [\n// { t: time, c: timeColor },\n { t: u_team, c: color },\n// { t: \" red card \", c: \"#FF272A\" },\n { t: \" No.\"+shirtNumber+ \" \", c: \"#FFFFFF\"},\n { t: u_player, c: \"#e74c3c\" },\n { t: \" SENT OFF \", c: \"#FF272A\"}\n ];\n\n} else if (eventType === \"disallowed goal\") {\n // No icon override here - disallowed goal shares the \"Goals\" icon\n // setting (goalsIcon, from the default assigned at the top of this\n // function) same as goal/penalty goal/own goal.\n text = [\n// { t: time, c: timeColor },\n { t: team, c: color },\n { t: \" goal disallowed \", c: \"#FFFFFF\" },\n { t: score, c: \"#87ae73\" }\n ];\n\n} else if (eventType === \"penalty goal\") {\n // textCase 2, same reasoning as red card above - preserves the\n // literal \"No.\" in the shirt-number segment below.\n textCase = 2\n text = [\n { t: time, c: timeColor },\n { t: team, c: color },\n { t: \" No.\"+shirtNumber+\" \", c: \"#FFFFFF\" },\n { t: u_surname+\"(PEN) \", c: \"#FFD700\" },\n { t: score, c: \"#87ae73\" }\n ];\n\n} else if (eventType === \"own goal\") {\n\n // No shirt-number \"No.\" literal here, so textCase stays at its\n // default of 1 - playerSurname is used as-is below (NOT the\n // pre-uppercased u_surname) since textCase:1 already auto-uppercases\n // it at render time, same visual result without doing it manually.\n const opponentShort = (teamId === homeTeamId) ? awayTeamShort : homeTeamShort;\n\n text = [\n { t: time, c: timeColor },\n { t: playerSurname+\"(\"+opponentShort+\")(OG) \", c: \"#FF272A\" },\n { t: \"| \", c: \"#ffffff\" },\n { t: team, c: color },\n { t: \" \", c: \"#ffffff\" },\n { t: score, c: \"#87ae73\" }\n ];\n\n} else {\n\n // \"goal\" and any unrecognised event type fall back to this, the\n // original behaviour. textCase 2 again, same reasoning as red card /\n // penalty goal above - preserves the literal \"No.\" in the\n // shirt-number segment below.\n textCase = 2;\n text = [\n { t: time, c: timeColor },\n { t: u_team, c: color },\n { t: \" No.\"+shirtNumber+\" \", c: \"#FFFFFF\" },\n { t: u_surname+\" \", c: \"#FFD700\" },\n { t: score, c: \"#87ae73\" }\n ];\n}\n\nconst finalPayload = {\n scrollSpeed: 70,\n textCase: textCase,\n repeat: 1,\n stack: true,\n text: text\n};\nif (icon !== 0) {\n finalPayload.icon = icon;\n}\n\nreturn { payload: finalPayload };",
493
+ "func": "// -----------------------------------------------------------------------------\n// Team / score continuation card (pin3 of \"queue dispatcher\").\n//\n// Builds a different AWTRIX message depending on the event type carried\n// on msg.eventType. Note this is the REAL eventType (never folded to\n// \"goal\" the way pin1/pin2 are in queue dispatcher's buildBlock) - this\n// is the only place penalty goal / own goal actually look different\n// from a normal goal.\n//\n// - goal: team + score, with the home/away \"*\" marker,\n// shirt number + surname in gold\n// - penalty goal: same as goal, plus a \"(PEN)\" tag after the surname\n// - own goal: \"OWN GOAL\" label, scorer's shirt number + surname,\n// the OPPONENT team's short name (the scorer's own\n// team, coloured with their own resolved colour),\n// then the benefiting team + score\n// - disallowed goal: team + score (marker included), phrased to make\n// clear the goal did not count\n// - red card: team + player name - no scoreline, since a red\n// card doesn't change the score\n// - full time: detailed FT scoreline (team names, colours,\n// score) - moved here from queue dispatcher's pin2,\n// which now just sends a quick \"Full Time\" gold\n// flash card instead. Brings full time in line with\n// every other event type (short pin2 interrupt +\n// detailed pin3 continuation). Reads straight off\n// msg.payload (the raw eventData object) rather\n// than the flattened fields below, since those\n// don't carry homeTeam/awayTeam/homeScore/\n// awayScore/homeTeamColor/awayTeamColor.\n//\n// msg.time / msg.team / msg.score / msg.color / msg.player /\n// msg.playerSurname / msg.shirtNum / msg.teamId / msg.homeTeamId /\n// msg.awayTeamId / msg.homeTeamShort / msg.awayTeamShort / msg.eventType\n// are all flattened top-level fields set upstream by \"queue dispatcher\"\n// (baseMsg in buildBlock). msg.payload carries the raw eventData object\n// on every event type - only the full time branch below uses it.\n// -----------------------------------------------------------------------------\n\n// Flattened fields from queue dispatcher's baseMsg - not every event\n// type below uses every field (e.g. shirtNumber only matters for\n// goal/penalty goal/red card/own goal; homeTeamId/awayTeamId/\n// homeTeamShort/awayTeamShort only matter for own goal, to work out the\n// OPPONENT's short name).\nconst time = (msg.time || \"\") + \" \";\nconst team = msg.team || \"\";\nconst score = msg.score || \"\";\nconst color = \"#\" + (msg.color || \"FFFFFF\");\nconst player = msg.player || \"\";\nconst playerSurname = msg.playerSurname || \"\";\nconst shirtNumber = msg.shirtNum || \"\";\nconst teamId = msg.teamId || \"\";\nconst homeTeamId = msg.homeTeamId || \"\";\nconst awayTeamId = msg.awayTeamId || \"\";\nconst homeTeamShort = msg.homeTeamShort || \"\";\nconst awayTeamShort = msg.awayTeamShort || \"\";\nconst eventType = msg.eventType || \"\";\n// Icon IDs are configurable per subflow instance via Environment\n// Variables (goalsIcon / redCardIcon / fullTimeIcon) - see queue\n// dispatcher's header for why. 0 means \"no icon\" - the icon key is\n// omitted from the final payload entirely rather than sending a\n// meaningless id. \"Goals\" covers goal, penalty goal, own goal, AND\n// disallowed goal - they all share the same icon setting.\nconst goalsIcon = Number(env.get(\"goalsIcon\")) || 0;\nconst redCardIcon = Number(env.get(\"redCardIcon\")) || 0;\nconst fullTimeIcon = Number(env.get(\"fullTimeIcon\")) || 0;\n\nlet icon = goalsIcon;\nlet timeColor = \"#3BB143\";\nlet textCase = 1;\n\nlet u_team = team.toUpperCase();\nlet u_player = player.toUpperCase();\nlet u_surname = playerSurname.toUpperCase();\n\n// -----------------------------------------------------------------------------\n// Scorer info (player name + shirt number) can lag several polls behind\n// the score/card itself updating in ESPN's data - confirmed via real log\n// evidence, a goal's score changed 4+ polls before the scorer's name and\n// jersey number were populated. Firing with a blank name looks broken.\n// Waiting for the data to arrive was rejected as too slow (stacks\n// polling delay from client+VPS+ESPN, risks queue pile-up behind other\n// live matches) - instead, this fires immediately either way, but\n// gracefully drops the scorer segment when it's not there yet rather\n// than showing a blank gap. playerSurname and shirtNumber are treated\n// as one combined all-or-nothing signal (confirmed they arrive/lag\n// together, never independently in ESPN's data), so checking\n// playerSurname alone is enough to know if BOTH are missing.\n// -----------------------------------------------------------------------------\nconst hasScorerInfo = !!playerSurname;\n\nlet text;\n\nif (eventType === \"full time\") {\n // Detailed FT scoreline - moved here from queue dispatcher's pin2,\n // exact same fields/formatting as before, just relocated. msg.payload\n // here is the raw eventData object (set by queue dispatcher's\n // buildBlock), not the flattened baseMsg fields used everywhere else\n // in this function - that's why this branch reads from eventData\n // directly instead of the const declarations above.\n const eventData = msg.payload || {};\n const homeTeam = eventData.homeTeam || \"\";\n const awayTeam = eventData.awayTeam || \"\";\n const homeScore = eventData.homeScore;\n const awayScore = eventData.awayScore;\n const homeTeamColor = \"#\" + (eventData.homeTeamColor || \"FFFFFF\");\n const awayTeamColor = \"#\" + (eventData.awayTeamColor || \"FFFFFF\");\n\n // Early return rather than falling through to the shared return at\n // the bottom - this card needs repeat:2 and pushIcon:2, which differ\n // from every other event type's defaults (repeat:1, no pushIcon).\n const fullTimePayload = {\n scrollSpeed: 70,\n pushIcon: 2,\n textCase: 1,\n repeat: 2,\n stack: true,\n text: [\n { t: \"FT \", c: \"#FFD700\" },\n { t: \"| \", c: \"#ffffff\" },\n { t: homeTeam+\" \", c: homeTeamColor },\n { t: homeScore+\" -\"+awayScore+\" \", c: \"#87ae73\" },\n { t: awayTeam, c: awayTeamColor }\n ],\n };\n if (fullTimeIcon !== 0) {\n fullTimePayload.icon = fullTimeIcon;\n }\n return { payload: fullTimePayload };\n\n} else if (eventType === \"red card\") {\n icon = redCardIcon;\n\n if (hasScorerInfo) {\n // textCase 2 preserves exact written case - the \"No.\" shirt-number\n // literal below would otherwise get mangled by AWTRIX's textCase:1\n // auto-uppercase. u_team/u_player are pre-uppercased manually\n // instead, to get the \"caps\" look without relying on textCase:1.\n textCase = 2;\n text = [\n // { t: time, c: timeColor },\n { t: u_team, c: color },\n // { t: \" red card \", c: \"#FF272A\" },\n { t: \" No.\"+shirtNumber+ \" \", c: \"#FFFFFF\"},\n { t: u_player, c: \"#e74c3c\" },\n { t: \" SENT OFF \", c: \"#FF272A\"}\n ];\n } else {\n // No scorer/carded-player data yet - a red card notification\n // with no player name is far less useful than a goal one, since\n // the player IS the point of the alert, but team/event info is\n // still shown rather than nothing at all. No textCase 2 needed\n // here since there's no \"No.\" literal in this branch.\n text = [\n { t: \"RED CARD \", c: \"#FF272A\" },\n { t: \"| \", c: \"#87ae73\" },\n { t: u_team, c: color }\n ];\n }\n\n} else if (eventType === \"disallowed goal\") {\n // No icon override here - disallowed goal shares the \"Goals\" icon\n // setting (goalsIcon, from the default assigned at the top of this\n // function) same as goal/penalty goal/own goal. Never carries scorer\n // info at all (a score DECREASE has no scoring player to credit), so\n // no hasScorerInfo branching needed here.\n text = [\n // { t: time, c: timeColor },\n { t: team, c: color },\n { t: \" goal disallowed \", c: \"#FFFFFF\" },\n { t: score, c: \"#87ae73\" }\n ];\n\n} else if (eventType === \"penalty goal\") {\n if (hasScorerInfo) {\n // textCase 2, same reasoning as red card above - preserves the\n // literal \"No.\" in the shirt-number segment below.\n textCase = 2;\n text = [\n { t: time, c: timeColor },\n { t: u_team, c: color },\n { t: \" No.\"+shirtNumber+\" \", c: \"#FFFFFF\" },\n { t: u_surname+\"(PEN) \", c: \"#FFD700\" },\n { t: score, c: \"#87ae73\" }\n ];\n } else {\n // The penalty flag (penaltyKick) is independent of\n // athletesInvolved, so we still know it was a penalty even\n // without the scorer's name - the \"(PEN)\" tag is kept, only the\n // scorer's identity is dropped. No \"No.\" literal in this branch,\n // so textCase stays at the default of 1.\n text = [\n { t: time, c: timeColor },\n { t: u_team+\" \", c: color },\n { t: \"(PEN) \", c: \"#FFD700\" },\n { t: score, c: \"#87ae73\" }\n ];\n }\n\n} else if (eventType === \"own goal\") {\n\n // Which side is the OPPONENT (the own-goal scorer's real team, not\n // the benefiting team this event is attributed to) - used for both\n // their short code and their team colour below. Both are independent\n // of scorer info (homeTeamShort/awayTeamShort come from\n // upcomingMatches, not athletesInvolved), so they're available even\n // when the scorer's name is missing.\n const opponentShort = (teamId === homeTeamId) ? awayTeamShort : homeTeamShort;\n\n // Opponent's team colour isn't in any field already passed to this\n // function - looked up directly from flow.upcomingMatches by\n // matchKey, same source \"upcoming matches (advanced)\" itself uses.\n // Those stored colours are already AWTRIX-safe (brightness-checked,\n // clash-avoided) - this is a lookup of an already-resolved value,\n // not a fresh evaluation.\n const upcomingMatches = flow.get(\"upcomingMatches\") || [];\n const uniqueID = String(msg.uid || \"\").split(\"|\")[0];\n const match = upcomingMatches.find(function (m) { return String(m.uniqueID) === uniqueID; });\n const isHomeOpponent = (teamId !== homeTeamId);\n const opponentColorHex = match ? (isHomeOpponent ? match.homeTeamColor : match.awayTeamColor) : null;\n const opponentColor = \"#\" + (opponentColorHex || \"FFFFFF\");\n\n if (hasScorerInfo) {\n // textCase 2 needed - the literal \"No.\" prefix would otherwise\n // get mangled by AWTRIX's textCase:1 auto-uppercase, same\n // reasoning as red card/penalty goal/goal above.\n textCase = 2;\n text = [\n { t: time, c: timeColor },\n { t: \"OWN GOAL \", c: \"#87ae73\" },\n { t: \"No.\", c: \"#3BB143\" },\n { t: shirtNumber+\" \", c: \"#FFFFFF\" },\n { t: u_surname+\" \", c: \"#FFD700\" },\n { t: \"(\"+opponentShort+\") \", c: opponentColor },\n { t: \"| \", c: \"#87ae73\" },\n { t: u_team+\" \", c: color },\n { t: score, c: \"#87ae73\" }\n ];\n } else {\n // Opponent team is still shown even without the scorer's name -\n // \"whose team it was\" is still meaningful, \"who\" specifically\n // isn't available yet. No \"No.\" literal in this branch, so\n // textCase stays at the default of 1.\n text = [\n { t: time, c: timeColor },\n { t: \"OWN GOAL \", c: \"#87ae73\" },\n { t: \"(\"+opponentShort+\") \", c: opponentColor },\n { t: \"| \", c: \"#87ae73\" },\n { t: u_team+\" \", c: color },\n { t: score, c: \"#87ae73\" }\n ];\n }\n\n} else {\n\n // \"goal\" and any unrecognised event type fall back to this, the\n // original behaviour.\n if (hasScorerInfo) {\n // textCase 2 again, same reasoning as red card / penalty goal\n // above - preserves the literal \"No.\" in the shirt-number\n // segment below.\n textCase = 2;\n text = [\n { t: time, c: timeColor },\n { t: u_team, c: color },\n { t: \" No.\"+shirtNumber+\" \", c: \"#FFFFFF\" },\n { t: u_surname+\" \", c: \"#FFD700\" },\n { t: score, c: \"#87ae73\" }\n ];\n } else {\n // No \"No.\" literal here, so textCase stays at the default of 1.\n text = [\n { t: time, c: timeColor },\n { t: u_team+\" \", c: color },\n { t: score, c: \"#87ae73\" }\n ];\n }\n}\n\nconst finalPayload = {\n scrollSpeed: 70,\n textCase: textCase,\n repeat: 1,\n stack: true,\n text: text\n};\nif (icon !== 0) {\n finalPayload.icon = icon;\n}\n\nreturn { payload: finalPayload };",
494
494
  "outputs": 1,
495
495
  "timeout": 0,
496
496
  "noerr": 0,
@@ -800,7 +800,7 @@
800
800
  "type": "function",
801
801
  "z": "8f04e1df3ff1c424",
802
802
  "name": "in play matches (live)",
803
- "func": "/***********************************************************************\n * Soccer Goal Detection Function\n *\n * Monitors live soccer matches and outputs an event when:\n * 1. A team score increases = goal (further classified as \"penalty\n * goal\" or \"own goal\" if ESPN flags it that way - see the\n * GOAL INFORMATION section below)\n * 2. A team score decreases = disallowed goal\n * 3. The red card count increases = red card\n * 4. The match reaches a recognised final status = full time (this\n * also stamps finishedAt on the stored match entry, which is what\n * the separate \"finished match cleanup\" function later uses to\n * remove it from flow.matchesState - see the FULL TIME block below)\n *\n * Match state is stored in flow context:\n *\n * flow.matchesState\n *\n * Each in-play match gets its own unique entry using a composite key.\n **********************************************************************/\n\n// -----------------------------------------------------------------------------\n// Shared constants, used across the staleness filter, status checks, and\n// team-data building below. Centralised here so there is one place to tune\n// them rather than repeating the same values inline.\n// -----------------------------------------------------------------------------\n\nconst STALE_AFTER_MS = 4 * 60 * 60 * 1000; // 4 hours past kickoff\nconst FINAL_STATUS_NAMES = [\"STATUS_FULL_TIME\", \"STATUS_FINAL_PEN\", \"STATUS_FINAL_AET\"];\nconst RED_CARD_TYPE_TEXT = \"Red Card\";\nconst FALLBACK_COLOR = \"FFFFFF\";\n\n// ESPN supplies match period as a number - mapped here to a display label\n// instead of an if/else chain:\n// 1 = 1st half, 2 = 2nd half, 3 = 1st half ET, 4 = 2nd half ET, 5 = penalties\nconst MATCH_PERIOD_LABELS = {\n 1: \"1stHalf\",\n 2: \"2ndHalf\",\n 3: \"1stHalf(ET)\",\n 4: \"2ndHalf(ET)\",\n 5: \"PENALTIES\"\n};\n\nlet results = [];\n\n// Retrieve previous match state from flow context\nlet previousState = flow.get(\"matchesState\") || {};\n\n\n// store player surname for awtrix messages created downstream \nfunction getPlayerSurname(shortName) {\n if (!shortName) {\n return \"\";\n }\n const separatorIndex = shortName.indexOf(\". \");\n return separatorIndex === -1 ? shortName : shortName.slice(separatorIndex + 2);\n}\n\n\n// -----------------------------------------------------------------------------\n// Staleness safety net.\n//\n// A stored match is normally removed when an event with\n// status.type.state === \"post\" and a recognised final status name\n// (see FINAL_STATUS_NAMES) is seen for its matchKey. That removal can\n// never fire if the match disappears from the feed before that \"post\"\n// event is ever received - for example, a polling gap (network outage,\n// flow restart) that spans the point where the match actually ends. The\n// entry is then stuck in flow.matchesState permanently, still showing\n// its last-known in-play score.\n//\n// As a fallback, every stored matchKey embeds its kickoff time as its\n// second \"|\"-separated segment (id|date|name). On each run, any entry\n// whose embedded kickoff time is more than STALE_AFTER_MS in the past is\n// dropped here, regardless of whether a qualifying \"post\" event was ever\n// received for it.\n// -----------------------------------------------------------------------------\n\nconst now = Date.now();\n\nfor (const key of Object.keys(previousState)) {\n\n const kickoffTime = key.split(\"|\")[1];\n const kickoff = new Date(kickoffTime).getTime();\n\n // Keep the entry if the embedded date is missing/unparseable rather\n // than guessing - malformed keys should surface elsewhere, not be\n // silently discarded by the staleness filter.\n if (isNaN(kickoff)) {\n continue;\n }\n\n if ((now - kickoff) >= STALE_AFTER_MS) {\n delete previousState[key];\n }\n}\n\n\n\n// Retrieve upcoming matches so we can copy across stored team IDs,\n// colours, and short names (homeTeamShort/awayTeamShort) for the\n// currently in-play match - see the lookup below.\nlet upcomingMatches = flow.get(\"upcomingMatches\") || [];\n\n\n// Load the tracked-teams list (set by \"tracked teams config\")\nconst trackedTeams = flow.get(\"trackedTeams\") || [];\nconst trackedTeamIds = new Set(trackedTeams.map(function (t) { return t.id; }));\n\n\n// Process every event in the incoming events array\nfor (const event of msg.payload.events || []) {\n\n /*******************************************************************\n * Create a unique identifier for this match.\n *\n * event.id alone was found to be shared between simultaneous games,\n * so the key is built from multiple values:\n *\n * - event.id or event.uid\n * - event.date\n * - event.name\n *\n * This prevents two live games overwriting each other in flow context.\n ******************************************************************/\n const matchKey = [\n event.id || event.uid || \"no-id\",\n event.date || \"no-date\",\n event.name || \"no-name\"\n ].join(\"|\");\n\n const state = event.status?.type?.state;\n const statusName = event.status?.type?.name;\n const completed = event.status?.type?.completed;\n\n /*******************************************************************\n * FULL TIME\n *\n * Fires once, when ESPN reports state === \"post\" with one of the\n * FINAL_STATUS_NAMES. Rather than deleting the matchesState entry\n * immediately, this stamps finishedAt = Date.now() on it and pushes\n * a \"full time\" event downstream (for the FT AWTRIX card / Alexa\n * announcement). The entry itself is left in flow.matchesState -\n * removal is handled later, and independently, by the separate\n * \"finished match cleanup\" function (60 minutes after finishedAt),\n * so this function doesn't need to know or care about that timing.\n ******************************************************************/\n if (state === \"post\" && FINAL_STATUS_NAMES.includes(statusName)) {\n\n\n // * previousState = matchesState variable\n // * matchKey = Unique identifier for the match\n // * create finishedMatch with all the stored values for that entry\n //\n // (diagram simplified to the fields that matter for illustrating\n // the lookup - the real stored entry also carries homeTeam/\n // awayTeam names, homeTeamColor/awayTeamColor, matchPeriod,\n // matchClock, redCardCount)\n /* previousState\n |\n │ Unique matchKey finishedMatch now adopts these fields on each iteration of the loop\n ├── \"401886533\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore }\n │ Unique matchKey \n ├── \"401900123\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore }\n │ Unique matchKey \n └── \"401900456\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore } */\n\n\n\n const finishedMatch = previousState[matchKey];\n\n if (!finishedMatch) {\n continue;\n }\n\n if (finishedMatch.finishedAt) {\n continue;\n }\n\n\n finishedMatch.finishedAt = Date.now();\n\n results.push({\n matchKey,\n team: \"\",\n teamId: \"\",\n homeTeam: finishedMatch.homeTeam || \"\",\n awayTeam: finishedMatch.awayTeam || \"\",\n homeTeamId: finishedMatch.homeTeamId || \"\",\n awayTeamId: finishedMatch.awayTeamId || \"\",\n homeTeamColor: finishedMatch.homeTeamColor || \"\",\n awayTeamColor: finishedMatch.awayTeamColor || \"\",\n player: \"\",\n shirtNumber: \"\",\n event: \"full time\",\n matchClock: \"FT\",\n matchPeriod: \"FullTime\",\n homeScore: finishedMatch.homeScore,\n awayScore: finishedMatch.awayScore,\n mainColor: \"\"\n });\n\n continue;\n }\n \n\n /*******************************************************************\n * Only process matches currently in play.\n *\n * state === \"in\" && completed === false is sufficient on its own -\n * a calendar-date (\"today\") check was previously used here too, but\n * it wrongly cut off matches still live after midnight UTC (e.g. a\n * 22:00Z kickoff still in extra time at 00:30Z the next day), since\n * event.date is the fixed kickoff time and never changes while\n * \"today\" keeps advancing.\n *\n * Skip immediately rather than nesting the rest of the loop body\n * inside this check.\n ******************************************************************/\n if (state !== \"in\" || completed !== false) {\n continue;\n }\n\n // Skip matches involving neither tracked team - keeps matchesState limited\n // to matches we actually care about, same filter upcomingMatches applies.\n const homeIdRaw = String(event.competitions?.[0]?.competitors?.[0]?.team?.id || \"\");\n const awayIdRaw = String(event.competitions?.[0]?.competitors?.[1]?.team?.id || \"\");\n\n if (!trackedTeamIds.has(homeIdRaw) && !trackedTeamIds.has(awayIdRaw)) {\n continue;\n }\n\n /***************************************************************\n * Extract competitor objects.\n *\n * Expected structure:\n *\n * competitors[0] = home team\n * competitors[1] = away team\n **************************************************************/\n const homeCompetitor = event.competitions?.[0]?.competitors?.[0];\n const awayCompetitor = event.competitions?.[0]?.competitors?.[1];\n\n /***************************************************************\n * Validate competitor order.\n *\n * This protects against assigning goals to the wrong team if the\n * API ever returns the competitors in an unexpected order.\n **************************************************************/\n if (\n homeCompetitor?.homeAway !== \"home\" ||\n awayCompetitor?.homeAway !== \"away\"\n ) {\n node.warn(`Unexpected competitor order for match: ${event.name}`);\n continue;\n }\n\n /***************************************************************\n * Extract team names directly from the competitor team objects.\n *\n * Uses displayName (not name) so that team names here match what\n * the upcomingMatches function produces from the same ESPN\n * payload - the two previously used different fields, which\n * could disagree for teams where ESPN's \"name\" and\n * \"displayName\" differ (e.g. a shortened vs full team name).\n **************************************************************/\n const homeTeam = homeCompetitor?.team?.displayName || \"\";\n const awayTeam = awayCompetitor?.team?.displayName || \"\";\n\n /***************************************************************\n * Extract current scores.\n **************************************************************/\n const homeScore = Number(homeCompetitor?.score ?? 0);\n const awayScore = Number(awayCompetitor?.score ?? 0);\n\n /***************************************************************\n * Extract match clock.\n **************************************************************/\n const matchClock = event.status?.displayClock;\n\n /***************************************************************\n * Extract match period, mapped via MATCH_PERIOD_LABELS.\n **************************************************************/\n const matchPeriod = MATCH_PERIOD_LABELS[event.status?.period] || \"\";\n\n /***************************************************************\n * Find this match in the upcomingMatches flow variable.\n *\n * The upcomingMatches array stores team IDs, colours, and short\n * names against event.id:\n *\n * upcomingMatches[x].uniqueID\n * upcomingMatches[x].homeTeamId\n * upcomingMatches[x].awayTeamId\n * upcomingMatches[x].homeTeamShort\n * upcomingMatches[x].awayTeamShort\n * upcomingMatches[x].homeTeamColor\n * upcomingMatches[x].awayTeamColor\n * upcomingMatches[x].competitionKey\n **************************************************************/\n const upcomingMatch = upcomingMatches.find(function (match) {\n return String(match.uniqueID) === String(event.id);\n });\n\n const homeTeamId = upcomingMatch?.homeTeamId || \"\";\n const awayTeamId = upcomingMatch?.awayTeamId || \"\";\n const homeTeamColor = upcomingMatch?.homeTeamColor || FALLBACK_COLOR;\n const awayTeamColor = upcomingMatch?.awayTeamColor || FALLBACK_COLOR;\n const homeTeamShort = upcomingMatch?.homeTeamShort || \"\";\n const awayTeamShort = upcomingMatch?.awayTeamShort || \"\";\n const competitionKey = upcomingMatch?.competitionKey || \"\";\n \n\n /***************************************************************\n * RED CARD SECTION\n *\n * Count entries in the match details array where type.text\n * matches RED_CARD_TYPE_TEXT.\n **************************************************************/\n const details = event.competitions?.[0]?.details || [];\n\n const redCards = details.filter(function (detail) {\n return detail?.type?.text === RED_CARD_TYPE_TEXT;\n });\n\n const redCardCount = redCards.length; // stores the number of red card events found in the details[] array\n\n /***************************************************************\n * Retrieve previously stored score for this match.\n **************************************************************/\n const prev = previousState[matchKey];\n\n /***************************************************************\n * First time seeing this match. (FLOW VARIABLE)\n *\n * Store the current score as the baseline.\n * Do not output anything because no score change can be detected\n * until the next poll.\n **************************************************************/\n if (!prev) {\n previousState[matchKey] = {\n homeTeam,\n awayTeam,\n homeTeamId,\n awayTeamId,\n homeTeamColor,\n awayTeamColor,\n // Explicitly false unless upcomingMatches already reports\n // verified:true at this exact moment - see the self-heal\n // block below, which keeps retrying every poll until this\n // flips true from a genuinely resolved upcomingMatches entry.\n competitionKey,\n verified: !!upcomingMatch?.verified,\n homeScore,\n awayScore,\n matchPeriod,\n matchClock,\n redCardCount\n };\n\n continue;\n }\n\n\n // -----------------------------------------------------------------------------\n // matchClock and matchPeriod change continuously throughout the match, so\n // they're refreshed on every poll for an already-tracked match - not just\n // when a goal/red card is detected.\n // -----------------------------------------------------------------------------\n\n previousState[matchKey].matchClock = matchClock;\n previousState[matchKey].matchPeriod = matchPeriod;\n\n // Self-heal: homeTeamId/awayTeamId/homeTeamColor/awayTeamColor are\n // normally only written once, above, when this match's baseline is\n // first created - they don't change during a match, so there's no\n // need to keep rewriting them every poll. But if that first lookup\n // failed (upcomingMatches didn't have this match yet), those fields\n // would otherwise stay blank/FALLBACK_COLOR for the rest of the\n // match.\n //\n // IMPORTANT: this now defers to upcomingMatches' OWN \"verified\" flag,\n // not just \"did I get a non-blank homeTeamId\". upcomingMatches can\n // itself return a colour that's only a TEMPORARY placeholder (see\n // that function's own verified logic) if ESPN sent no colour data on\n // that particular poll - homeTeamId would already be non-blank at\n // that point (id/name/short are guaranteed complete before an\n // upcomingMatches entry is ever created at all), so the old\n // homeTeamId-only check would have copied over a placeholder colour\n // and then NEVER looked again, permanently disagreeing with\n // upcomingMatches even after it later resolved the real colour.\n // Checking upcomingMatch?.verified instead means this keeps retrying\n // every poll until upcomingMatches itself reports the colours are\n // genuinely resolved from real data, not just present.\n if (!previousState[matchKey].verified && upcomingMatch?.verified) {\n previousState[matchKey].homeTeamId = homeTeamId;\n previousState[matchKey].awayTeamId = awayTeamId;\n previousState[matchKey].homeTeamColor = homeTeamColor;\n previousState[matchKey].awayTeamColor = awayTeamColor;\n previousState[matchKey].competitionKey = competitionKey;\n previousState[matchKey].verified = true;\n }\n\n const prevHomeScore = Number(prev.homeScore ?? 0);\n const prevAwayScore = Number(prev.awayScore ?? 0);\n\n\n /***********************************************************************\n * MATCH EVENT PROCESSING\n *\n * Detect and output:\n *\n * - Goals\n * - Disallowed goals\n * - Red cards\n *\n * All event types use the same team data and output structure.\n ***********************************************************************/\n\n const prevRedCardCount = Number(prev.redCardCount ?? 0);\n\n let changed = false;\n\n /***********************************************************************\n * TEAM DATA\n *\n * Store the home and away team information in one reusable array.\n ***********************************************************************/\n\n const teams = [\n {\n team: homeTeam,\n teamId: homeTeamId,\n competitorTeamId: String(homeCompetitor?.team?.id || \"\"),\n currentScore: homeScore,\n previousScore: prevHomeScore,\n color: homeTeamColor\n },\n {\n team: awayTeam,\n teamId: awayTeamId,\n competitorTeamId: String(awayCompetitor?.team?.id || \"\"),\n currentScore: awayScore,\n previousScore: prevAwayScore,\n color: awayTeamColor\n }\n ];\n\n /***********************************************************************\n * Add a detected match event to the output.\n *\n * The event type, team and player are stored in separate fields.\n * Also carries playerSurname (via getPlayerSurname(), stripping the\n * \"X. \" prefix from ESPN's shortName) and homeTeamShort/awayTeamShort -\n * both added so downstream functions (queue dispatcher, team|score|\n * player) can label the OPPONENT team by short name for own-goal\n * display without a second lookup.\n ***********************************************************************/\n\n function addMatchEvent(teamData, eventType, eventClock, player = \"\", shirtNumber = \"\") {\n results.push({\n matchKey,\n team: teamData?.team || \"Unknown\",\n teamId: teamData?.teamId || \"\",\n homeTeamId,\n awayTeamId,\n homeTeamShort,\n awayTeamShort,\n shirtNumber,\n player,\n playerSurname: getPlayerSurname(player),\n event: eventType,\n matchClock: eventClock || matchClock || \"\",\n matchPeriod,\n homeScore,\n awayScore,\n mainColor: teamData?.color || FALLBACK_COLOR\n });\n\n changed = true;\n }\n\n\n\n\n /***********************************************************************\n * GOAL / DISALLOWED GOAL DETECTION\n ***********************************************************************/\n\n for (const teamData of teams) {\n\n if (teamData.currentScore > teamData.previousScore) {\n\n /*******************************************************************\n * GOAL INFORMATION\n *\n * Search the match details[] array for events where scoringPlay\n * is true and the event's team ID matches the team whose score\n * has increased.\n *\n * IMPORTANT: this used to filter on detail?.type?.text === \"Goal\",\n * but that was found to silently miss headers/own-goals/penalties,\n * since ESPN varies type.text by scoring method (e.g. \"Goal -\n * Header\", \"Penalty - Scored\", \"Own Goal\" - none of which is\n * literally \"Goal\"). detail?.scoringPlay === true is a flag ESPN\n * sets on every scoring detail regardless of how the goal was\n * scored, so that's what actually classifies something as a goal\n * now - not the type.text string.\n *\n * teamGoals[] - will contain only scoring-play event objects\n * belonging to the team whose score has increased.\n\n * details[] - assigned earlier from:\n * event.competitions[0].details\n\n *.filter() - built-in JavaScript function that automatically loops\n * through every object in details[] and returns a new array\n * containing only the objects that satisfy our conditions.\n * \n * function(detail) - detail is a temporary variable automatically\n * assigned to each object in details[], as filter() works through\n * the array one object at a time.\n * \n * detail?.scoringPlay === true\n * Checks whether the current event stored in 'detail' is a\n * scoring play (a goal, however it was scored - open play,\n * header, penalty, own goal, etc).\n * \n * String(detail?.team?.id || \"\") === teamData.competitorTeamId\n * Converts the current event's team id to a string and compares it\n * with the team whose score has increased.\n * Returns true if they match, otherwise false.\n * \n *******************************************************************/\n\n\n const teamGoals = details.filter(function (detail) {\n return (\n detail?.scoringPlay === true &&\n String(detail?.team?.id || \"\") === teamData.competitorTeamId\n );\n });\n\n\n /*******************************************************************\n * Select the latest goal event.\n *\n * teamGoals is an array of goal-event objects filtered from the\n * match details[] array above. Each object has the same structure\n * as an entry in details[] and contains properties such as:\n *\n * goal.team\n * goal.clock\n * goal.athletesInvolved\n * goal.type\n *\n * The newest goal-event object is selected here so its\n * properties can be accessed below.\n *******************************************************************/\n\n const goal = teamGoals[teamGoals.length - 1];\n\n /*******************************************************************\n * Extract scorer information from the selected goal object.\n *\n * The optional chaining (?.) safely traverses the goal object:\n *\n * goal\n * └── athletesInvolved[]\n * └── [0]\n * ├── shortName\n * └── jersey\n *\n * and:\n *\n * goal\n * └── clock\n * └── displayValue\n *\n * If any property is missing, an empty string is returned instead\n * of generating an error.\n *******************************************************************/\n\n const goalPlayer =\n goal?.athletesInvolved?.[0]?.shortName || \"\";\n\n const goalNumber =\n goal?.athletesInvolved?.[0]?.jersey || \"\";\n\n const goalClock =\n goal?.clock?.displayValue || matchClock || \"\";\n\n /*******************************************************************\n * Record the goal event.\n *\n * goalEventType stays \"goal\" for anything not specifically\n * flagged - headers, volleys, free-kicks, etc. Only the\n * penaltyKick/ownGoal flags on the matched goal object promote\n * it to \"penalty goal\"/\"own goal\", which downstream (queue\n * dispatcher / team|score|player) drives extra formatting (a\n * PEN tag, opponent short name for OG) while still looking like\n * a plain \"goal\" on the Alexa/AWTRIX-interrupt outputs.\n *******************************************************************/\n \n let goalEventType = \"goal\";\n\n if (goal?.penaltyKick === true) {\n goalEventType = \"penalty goal\";\n } else if (goal?.ownGoal === true) {\n goalEventType = \"own goal\";\n }\n\n addMatchEvent(\n teamData,\n goalEventType,\n goalClock,\n goalPlayer,\n goalNumber\n );\n\n } else if (teamData.currentScore < teamData.previousScore) {\n\n /*******************************************************************\n * A team's score has decreased.\n *\n * This is treated as a disallowed goal. No player information is\n * required because the scorer is no longer credited with a goal.\n *******************************************************************/\n\n addMatchEvent(\n teamData,\n \"disallowed goal\",\n matchClock\n );\n }\n}\n\n /***********************************************************************\n * RED CARD DETECTION\n *\n * Process only the latest red-card incident when the red-card count\n * has increased since the previous poll.\n ***********************************************************************/\n\n if (redCardCount > prevRedCardCount) {\n\n const redCard = redCards[redCards.length - 1];\n const redCardTeamId = String(redCard?.team?.id || \"\");\n\n const teamData = teams.find(function (team) {\n return team.competitorTeamId === redCardTeamId;\n });\n\n const redCardPlayer = redCard?.athletesInvolved?.[0]?.shortName || \"\";\n const redCardnumber = redCard?.athletesInvolved?.[0]?.jersey || \"\";\n const redCardClock = redCard?.clock?.displayValue || matchClock || \"\";\n\n addMatchEvent(teamData, \"red card\", redCardClock, redCardPlayer, redCardnumber);\n }\n\n /***************************************************************\n * Only update stored state when a score or red-card change\n * occurred. (FLOW VARIABLE)\n ***************************************************************/\n\n if (changed) {\n previousState[matchKey].homeScore = homeScore;\n previousState[matchKey].awayScore = awayScore;\n previousState[matchKey].redCardCount = redCardCount;\n }\n}\n\n// Save updated match state back to flow context\nflow.set(\"matchesState\", previousState);\n\n// Only suppress output when absolutely no change occurred\nif (results.length === 0) {\n return null;\n}\n\n// Return detected changes\nmsg.payload = results;\nreturn msg;",
803
+ "func": "/***********************************************************************\n * Soccer Goal Detection Function\n *\n * Monitors live soccer matches and outputs an event when:\n * 1. A team score increases = goal (further classified as \"penalty\n * goal\" or \"own goal\" if ESPN flags it that way - see the\n * GOAL INFORMATION section below)\n * 2. A team score decreases = disallowed goal\n * 3. The red card count increases = red card\n * 4. The match reaches a recognised final status = full time (this\n * also stamps finishedAt on the stored match entry, which is what\n * the separate \"finished match cleanup\" function later uses to\n * remove it from flow.matchesState - see the FULL TIME block below)\n *\n * Match state is stored in flow context:\n *\n * flow.matchesState\n *\n * Each in-play match gets its own unique entry using a composite key.\n **********************************************************************/\n\n// -----------------------------------------------------------------------------\n// Shared constants, used across the staleness filter, status checks, and\n// team-data building below. Centralised here so there is one place to tune\n// them rather than repeating the same values inline.\n// -----------------------------------------------------------------------------\n\nconst STALE_AFTER_MS = 4 * 60 * 60 * 1000; // 4 hours past kickoff\nconst FINAL_STATUS_NAMES = [\"STATUS_FULL_TIME\", \"STATUS_FINAL_PEN\", \"STATUS_FINAL_AET\"];\nconst RED_CARD_TYPE_TEXT = \"Red Card\";\nconst FALLBACK_COLOR = \"FFFFFF\";\n\n// ESPN supplies match period as a number - mapped here to a display label\n// instead of an if/else chain:\n// 1 = 1st half, 2 = 2nd half, 3 = 1st half ET, 4 = 2nd half ET, 5 = penalties\nconst MATCH_PERIOD_LABELS = {\n 1: \"1stHalf\",\n 2: \"2ndHalf\",\n 3: \"1stHalf(ET)\",\n 4: \"2ndHalf(ET)\",\n 5: \"PENALTIES\"\n};\n\nlet results = [];\n\n// Retrieve previous match state from flow context\nlet previousState = flow.get(\"matchesState\") || {};\n\n\n// -----------------------------------------------------------------------------\n// Convert accented/non-English characters down to plain ASCII, for\n// player names that AWTRIX's font can't render reliably (e.g. \"Šeško\",\n// \"Gündoğan\", \"Kovačić\", \"João\" -> \"Sesko\", \"Gundogan\", \"Kovacic\",\n// \"Joao\"). Combines Unicode NFD decomposition (splits an accented\n// character into its base letter + a separate combining accent mark,\n// then strips the accent) with a manual list for characters that don't\n// decompose that way at all (Ø, Ł, Đ, Þ, Ð, Æ, Œ, Turkish dotless ı) -\n// each manual rule handles both cases explicitly rather than a single\n// case-insensitive rule, since a single rule that always substitutes a\n// lowercase replacement would silently lowercase capitalised names\n// (e.g. \"Ødegaard\" incorrectly becoming \"odegaard\" instead of\n// \"Odegaard\").\n// -----------------------------------------------------------------------------\nfunction transliterate(name) {\n return String(name || \"\")\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/Ø/g, \"O\").replace(/ø/g, \"o\")\n .replace(/Ł/g, \"L\").replace(/ł/g, \"l\")\n .replace(/Đ/g, \"D\").replace(/đ/g, \"d\")\n .replace(/Þ/g, \"Th\").replace(/þ/g, \"th\")\n .replace(/Ð/g, \"D\").replace(/ð/g, \"d\")\n .replace(/Æ/g, \"Ae\").replace(/æ/g, \"ae\")\n .replace(/Œ/g, \"Oe\").replace(/œ/g, \"oe\")\n .replace(/ı/g, \"i\");\n}\n\n// store player surname for awtrix messages created downstream \nfunction getPlayerSurname(shortName) {\n if (!shortName) {\n return \"\";\n }\n const separatorIndex = shortName.indexOf(\". \");\n return separatorIndex === -1 ? shortName : shortName.slice(separatorIndex + 2);\n}\n\n\n// -----------------------------------------------------------------------------\n// Staleness safety net.\n//\n// A stored match is normally removed when an event with\n// status.type.state === \"post\" and a recognised final status name\n// (see FINAL_STATUS_NAMES) is seen for its matchKey. That removal can\n// never fire if the match disappears from the feed before that \"post\"\n// event is ever received - for example, a polling gap (network outage,\n// flow restart) that spans the point where the match actually ends. The\n// entry is then stuck in flow.matchesState permanently, still showing\n// its last-known in-play score.\n//\n// As a fallback, every stored matchKey embeds its kickoff time as its\n// second \"|\"-separated segment (id|date|name). On each run, any entry\n// whose embedded kickoff time is more than STALE_AFTER_MS in the past is\n// dropped here, regardless of whether a qualifying \"post\" event was ever\n// received for it.\n// -----------------------------------------------------------------------------\n\nconst now = Date.now();\n\nfor (const key of Object.keys(previousState)) {\n\n const kickoffTime = key.split(\"|\")[1];\n const kickoff = new Date(kickoffTime).getTime();\n\n // Keep the entry if the embedded date is missing/unparseable rather\n // than guessing - malformed keys should surface elsewhere, not be\n // silently discarded by the staleness filter.\n if (isNaN(kickoff)) {\n continue;\n }\n\n if ((now - kickoff) >= STALE_AFTER_MS) {\n delete previousState[key];\n }\n}\n\n\n\n// Retrieve upcoming matches so we can copy across stored team IDs,\n// colours, and short names (homeTeamShort/awayTeamShort) for the\n// currently in-play match - see the lookup below.\nlet upcomingMatches = flow.get(\"upcomingMatches\") || [];\n\n\n// Load the tracked-teams list (set by \"tracked teams config\")\nconst trackedTeams = flow.get(\"trackedTeams\") || [];\nconst trackedTeamIds = new Set(trackedTeams.map(function (t) { return t.id; }));\n\n\n// Process every event in the incoming events array\nfor (const event of msg.payload.events || []) {\n\n /*******************************************************************\n * Create a unique identifier for this match.\n *\n * event.id alone was found to be shared between simultaneous games,\n * so the key is built from multiple values:\n *\n * - event.id or event.uid\n * - event.date\n * - event.name\n *\n * This prevents two live games overwriting each other in flow context.\n ******************************************************************/\n const matchKey = [\n event.id || event.uid || \"no-id\",\n event.date || \"no-date\",\n event.name || \"no-name\"\n ].join(\"|\");\n\n const state = event.status?.type?.state;\n const statusName = event.status?.type?.name;\n const completed = event.status?.type?.completed;\n\n /*******************************************************************\n * FULL TIME\n *\n * Fires once, when ESPN reports state === \"post\" with one of the\n * FINAL_STATUS_NAMES. Rather than deleting the matchesState entry\n * immediately, this stamps finishedAt = Date.now() on it and pushes\n * a \"full time\" event downstream (for the FT AWTRIX card / Alexa\n * announcement). The entry itself is left in flow.matchesState -\n * removal is handled later, and independently, by the separate\n * \"finished match cleanup\" function (60 minutes after finishedAt),\n * so this function doesn't need to know or care about that timing.\n ******************************************************************/\n if (state === \"post\" && FINAL_STATUS_NAMES.includes(statusName)) {\n\n const finishedMatch = previousState[matchKey];\n\n if (!finishedMatch) {\n continue;\n }\n\n if (finishedMatch.finishedAt) {\n continue;\n }\n\n\n finishedMatch.finishedAt = Date.now();\n\n results.push({\n matchKey,\n team: \"\",\n teamId: \"\",\n homeTeam: finishedMatch.homeTeam || \"\",\n awayTeam: finishedMatch.awayTeam || \"\",\n homeTeamId: finishedMatch.homeTeamId || \"\",\n awayTeamId: finishedMatch.awayTeamId || \"\",\n homeTeamColor: finishedMatch.homeTeamColor || \"\",\n awayTeamColor: finishedMatch.awayTeamColor || \"\",\n player: \"\",\n shirtNumber: \"\",\n event: \"full time\",\n matchClock: \"FT\",\n matchPeriod: \"FullTime\",\n homeScore: finishedMatch.homeScore,\n awayScore: finishedMatch.awayScore,\n mainColor: \"\"\n });\n\n continue;\n }\n \n\n /*******************************************************************\n * Only process matches currently in play.\n *\n * state === \"in\" && completed === false is sufficient on its own -\n * a calendar-date (\"today\") check was previously used here too, but\n * it wrongly cut off matches still live after midnight UTC (e.g. a\n * 22:00Z kickoff still in extra time at 00:30Z the next day), since\n * event.date is the fixed kickoff time and never changes while\n * \"today\" keeps advancing.\n *\n * Skip immediately rather than nesting the rest of the loop body\n * inside this check.\n ******************************************************************/\n if (state !== \"in\" || completed !== false) {\n continue;\n }\n\n // Skip matches involving neither tracked team - keeps matchesState limited\n // to matches we actually care about, same filter upcomingMatches applies.\n const homeIdRaw = String(event.competitions?.[0]?.competitors?.[0]?.team?.id || \"\");\n const awayIdRaw = String(event.competitions?.[0]?.competitors?.[1]?.team?.id || \"\");\n\n if (!trackedTeamIds.has(homeIdRaw) && !trackedTeamIds.has(awayIdRaw)) {\n continue;\n }\n\n /***************************************************************\n * Extract competitor objects.\n *\n * Expected structure:\n *\n * competitors[0] = home team\n * competitors[1] = away team\n **************************************************************/\n const homeCompetitor = event.competitions?.[0]?.competitors?.[0];\n const awayCompetitor = event.competitions?.[0]?.competitors?.[1];\n\n /***************************************************************\n * Validate competitor order.\n *\n * This protects against assigning goals to the wrong team if the\n * API ever returns the competitors in an unexpected order.\n **************************************************************/\n if (\n homeCompetitor?.homeAway !== \"home\" ||\n awayCompetitor?.homeAway !== \"away\"\n ) {\n node.warn(`Unexpected competitor order for match: ${event.name}`);\n continue;\n }\n\n /***************************************************************\n * Extract team names directly from the competitor team objects.\n *\n * Uses displayName (not name) so that team names here match what\n * the upcomingMatches function produces from the same ESPN\n * payload - the two previously used different fields, which\n * could disagree for teams where ESPN's \"name\" and\n * \"displayName\" differ (e.g. a shortened vs full team name).\n **************************************************************/\n const homeTeam = homeCompetitor?.team?.displayName || \"\";\n const awayTeam = awayCompetitor?.team?.displayName || \"\";\n\n /***************************************************************\n * Extract current scores.\n **************************************************************/\n const homeScore = Number(homeCompetitor?.score ?? 0);\n const awayScore = Number(awayCompetitor?.score ?? 0);\n\n /***************************************************************\n * Extract match clock.\n **************************************************************/\n const matchClock = event.status?.displayClock;\n\n /***************************************************************\n * Extract match period, mapped via MATCH_PERIOD_LABELS.\n **************************************************************/\n const matchPeriod = MATCH_PERIOD_LABELS[event.status?.period] || \"\";\n\n /***************************************************************\n * Find this match in the upcomingMatches flow variable.\n *\n * The upcomingMatches array stores team IDs, colours, and short\n * names against event.id:\n *\n * upcomingMatches[x].uniqueID\n * upcomingMatches[x].homeTeamId\n * upcomingMatches[x].awayTeamId\n * upcomingMatches[x].homeTeamShort\n * upcomingMatches[x].awayTeamShort\n * upcomingMatches[x].homeTeamColor\n * upcomingMatches[x].awayTeamColor\n * upcomingMatches[x].competitionKey\n **************************************************************/\n const upcomingMatch = upcomingMatches.find(function (match) {\n return String(match.uniqueID) === String(event.id);\n });\n\n const homeTeamId = upcomingMatch?.homeTeamId || \"\";\n const awayTeamId = upcomingMatch?.awayTeamId || \"\";\n const homeTeamColor = upcomingMatch?.homeTeamColor || FALLBACK_COLOR;\n const awayTeamColor = upcomingMatch?.awayTeamColor || FALLBACK_COLOR;\n const homeTeamShort = upcomingMatch?.homeTeamShort || \"\";\n const awayTeamShort = upcomingMatch?.awayTeamShort || \"\";\n const competitionKey = upcomingMatch?.competitionKey || \"\";\n\n /***************************************************************\n * RED CARD SECTION\n *\n * Count entries in the match details array where type.text\n * matches RED_CARD_TYPE_TEXT.\n **************************************************************/\n const details = event.competitions?.[0]?.details || [];\n\n const redCards = details.filter(function (detail) {\n return detail?.type?.text === RED_CARD_TYPE_TEXT;\n });\n\n const redCardCount = redCards.length; // stores the number of red card events found in the details[] array\n\n /***************************************************************\n * Retrieve previously stored score for this match.\n **************************************************************/\n const prev = previousState[matchKey];\n\n /***************************************************************\n * First time seeing this match. (FLOW VARIABLE)\n *\n * Store the current score as the baseline.\n * Do not output anything because no score change can be detected\n * until the next poll.\n **************************************************************/\n if (!prev) {\n previousState[matchKey] = {\n homeTeam,\n awayTeam,\n homeTeamId,\n awayTeamId,\n homeTeamColor,\n awayTeamColor,\n competitionKey,\n // Explicitly false unless upcomingMatches already reports\n // verified:true at this exact moment - see the self-heal\n // block below, which keeps retrying every poll until this\n // flips true from a genuinely resolved upcomingMatches entry.\n verified: !!upcomingMatch?.verified,\n homeScore,\n awayScore,\n matchPeriod,\n matchClock,\n redCardCount\n };\n\n continue;\n }\n\n\n // -----------------------------------------------------------------------------\n // matchClock and matchPeriod change continuously throughout the match, so\n // they're refreshed on every poll for an already-tracked match - not just\n // when a goal/red card is detected.\n // -----------------------------------------------------------------------------\n\n previousState[matchKey].matchClock = matchClock;\n previousState[matchKey].matchPeriod = matchPeriod;\n\n // Self-heal: homeTeamId/awayTeamId/homeTeamColor/awayTeamColor/\n // competitionKey are normally only written once, above, when this\n // match's baseline is first created - they don't change during a\n // match, so there's no need to keep rewriting them every poll. But if\n // that first lookup failed (upcomingMatches didn't have this match\n // yet), those fields would otherwise stay blank/FALLBACK_COLOR for\n // the rest of the match.\n //\n // IMPORTANT: this now defers to upcomingMatches' OWN \"verified\" flag,\n // not just \"did I get a non-blank homeTeamId\". upcomingMatches can\n // itself return a colour that's only a TEMPORARY placeholder (see\n // that function's own verified logic) if ESPN sent no colour data on\n // that particular poll - homeTeamId would already be non-blank at\n // that point (id/name/short are guaranteed complete before an\n // upcomingMatches entry is ever created at all), so the old\n // homeTeamId-only check would have copied over a placeholder colour\n // and then NEVER looked again, permanently disagreeing with\n // upcomingMatches even after it later resolved the real colour.\n // Checking upcomingMatch?.verified instead means this keeps retrying\n // every poll until upcomingMatches itself reports the colours are\n // genuinely resolved from real data, not just present.\n if (!previousState[matchKey].verified && upcomingMatch?.verified) {\n previousState[matchKey].homeTeamId = homeTeamId;\n previousState[matchKey].awayTeamId = awayTeamId;\n previousState[matchKey].homeTeamColor = homeTeamColor;\n previousState[matchKey].awayTeamColor = awayTeamColor;\n previousState[matchKey].competitionKey = competitionKey;\n previousState[matchKey].verified = true;\n }\n\n const prevHomeScore = Number(prev.homeScore ?? 0);\n const prevAwayScore = Number(prev.awayScore ?? 0);\n\n\n /***********************************************************************\n * MATCH EVENT PROCESSING\n *\n * Detect and output:\n *\n * - Goals\n * - Disallowed goals\n * - Red cards\n *\n * All event types use the same team data and output structure.\n ***********************************************************************/\n\n const prevRedCardCount = Number(prev.redCardCount ?? 0);\n\n let changed = false;\n\n /***********************************************************************\n * TEAM DATA\n *\n * Store the home and away team information in one reusable array.\n ***********************************************************************/\n\n const teams = [\n {\n team: homeTeam,\n teamId: homeTeamId,\n competitorTeamId: String(homeCompetitor?.team?.id || \"\"),\n currentScore: homeScore,\n previousScore: prevHomeScore,\n color: homeTeamColor\n },\n {\n team: awayTeam,\n teamId: awayTeamId,\n competitorTeamId: String(awayCompetitor?.team?.id || \"\"),\n currentScore: awayScore,\n previousScore: prevAwayScore,\n color: awayTeamColor\n }\n ];\n\n /***********************************************************************\n * Add a detected match event to the output.\n *\n * The event type, team and player are stored in separate fields.\n * Also carries playerSurname (via getPlayerSurname(), stripping the\n * \"X. \" prefix from ESPN's shortName) and homeTeamShort/awayTeamShort -\n * both added so downstream functions (queue dispatcher, team|score|\n * player) can label the OPPONENT team by short name for own-goal\n * display without a second lookup.\n *\n * player is run through transliterate() once here, so both this\n * field and the playerSurname derived from it are already\n * AWTRIX-safe (accented/non-English characters converted to plain\n * ASCII) by the time they leave this function - nothing downstream\n * needs to remember to apply it separately.\n ***********************************************************************/\n\n function addMatchEvent(teamData, eventType, eventClock, player = \"\", shirtNumber = \"\") {\n const cleanPlayer = transliterate(player);\n results.push({\n matchKey,\n team: teamData?.team || \"Unknown\",\n teamId: teamData?.teamId || \"\",\n homeTeamId,\n awayTeamId,\n homeTeamShort,\n awayTeamShort,\n shirtNumber,\n player: cleanPlayer,\n playerSurname: getPlayerSurname(cleanPlayer),\n event: eventType,\n matchClock: eventClock || matchClock || \"\",\n matchPeriod,\n homeScore,\n awayScore,\n mainColor: teamData?.color || FALLBACK_COLOR\n });\n\n changed = true;\n }\n\n\n\n\n /***********************************************************************\n * GOAL / DISALLOWED GOAL DETECTION\n ***********************************************************************/\n\n for (const teamData of teams) {\n\n if (teamData.currentScore > teamData.previousScore) {\n\n /*******************************************************************\n * GOAL INFORMATION\n *\n * Search the match details[] array for events where scoringPlay\n * is true and the event's team ID matches the team whose score\n * has increased.\n *\n * IMPORTANT: this used to filter on detail?.type?.text === \"Goal\",\n * but that was found to silently miss headers/own-goals/penalties,\n * since ESPN varies type.text by scoring method (e.g. \"Goal -\n * Header\", \"Penalty - Scored\", \"Own Goal\" - none of which is\n * literally \"Goal\"). detail?.scoringPlay === true is a flag ESPN\n * sets on every scoring detail regardless of how the goal was\n * scored, so that's what actually classifies something as a goal\n * now - not the type.text string.\n *\n * teamGoals[] - will contain only scoring-play event objects\n * belonging to the team whose score has increased.\n\n * details[] - assigned earlier from:\n * event.competitions[0].details\n\n *.filter() - built-in JavaScript function that automatically loops\n * through every object in details[] and returns a new array\n * containing only the objects that satisfy our conditions.\n * \n * function(detail) - detail is a temporary variable automatically\n * assigned to each object in details[], as filter() works through\n * the array one object at a time.\n * \n * detail?.scoringPlay === true\n * Checks whether the current event stored in 'detail' is a\n * scoring play (a goal, however it was scored - open play,\n * header, penalty, own goal, etc).\n * \n * String(detail?.team?.id || \"\") === teamData.competitorTeamId\n * Converts the current event's team id to a string and compares it\n * with the team whose score has increased.\n * Returns true if they match, otherwise false.\n * \n *******************************************************************/\n\n\n const teamGoals = details.filter(function (detail) {\n return (\n detail?.scoringPlay === true &&\n String(detail?.team?.id || \"\") === teamData.competitorTeamId\n );\n });\n\n\n /*******************************************************************\n * Select the latest goal event.\n *\n * teamGoals is an array of goal-event objects filtered from the\n * match details[] array above. Each object has the same structure\n * as an entry in details[] and contains properties such as:\n *\n * goal.team\n * goal.clock\n * goal.athletesInvolved\n * goal.type\n *\n * The newest goal-event object is selected here so its\n * properties can be accessed below.\n *******************************************************************/\n\n const goal = teamGoals[teamGoals.length - 1];\n\n /*******************************************************************\n * Extract scorer information from the selected goal object.\n *\n * The optional chaining (?.) safely traverses the goal object:\n *\n * goal\n * └── athletesInvolved[]\n * └── [0]\n * ├── shortName\n * └── jersey\n *\n * and:\n *\n * goal\n * └── clock\n * └── displayValue\n *\n * If any property is missing, an empty string is returned instead\n * of generating an error.\n *******************************************************************/\n\n const goalPlayer =\n goal?.athletesInvolved?.[0]?.shortName || \"\";\n\n const goalNumber =\n goal?.athletesInvolved?.[0]?.jersey || \"\";\n\n const goalClock =\n goal?.clock?.displayValue || matchClock || \"\";\n\n /*******************************************************************\n * Record the goal event.\n *\n * goalEventType stays \"goal\" for anything not specifically\n * flagged - headers, volleys, free-kicks, etc. Only the\n * penaltyKick/ownGoal flags on the matched goal object promote\n * it to \"penalty goal\"/\"own goal\", which downstream (queue\n * dispatcher / team|score|player) drives extra formatting (a\n * PEN tag, opponent short name for OG) while still looking like\n * a plain \"goal\" on the Alexa/AWTRIX-interrupt outputs.\n *******************************************************************/\n \n let goalEventType = \"goal\";\n\n if (goal?.penaltyKick === true) {\n goalEventType = \"penalty goal\";\n } else if (goal?.ownGoal === true) {\n goalEventType = \"own goal\";\n }\n\n addMatchEvent(\n teamData,\n goalEventType,\n goalClock,\n goalPlayer,\n goalNumber\n );\n\n } else if (teamData.currentScore < teamData.previousScore) {\n\n /*******************************************************************\n * A team's score has decreased.\n *\n * This is treated as a disallowed goal. No player information is\n * required because the scorer is no longer credited with a goal.\n *******************************************************************/\n\n addMatchEvent(\n teamData,\n \"disallowed goal\",\n matchClock\n );\n }\n}\n\n /***********************************************************************\n * RED CARD DETECTION\n *\n * Process only the latest red-card incident when the red-card count\n * has increased since the previous poll.\n ***********************************************************************/\n\n if (redCardCount > prevRedCardCount) {\n\n const redCard = redCards[redCards.length - 1];\n const redCardTeamId = String(redCard?.team?.id || \"\");\n\n const teamData = teams.find(function (team) {\n return team.competitorTeamId === redCardTeamId;\n });\n\n const redCardPlayer = redCard?.athletesInvolved?.[0]?.shortName || \"\";\n const redCardnumber = redCard?.athletesInvolved?.[0]?.jersey || \"\";\n const redCardClock = redCard?.clock?.displayValue || matchClock || \"\";\n\n addMatchEvent(teamData, \"red card\", redCardClock, redCardPlayer, redCardnumber);\n }\n\n /***************************************************************\n * Only update stored state when a score or red-card change\n * occurred. (FLOW VARIABLE)\n ***************************************************************/\n\n if (changed) {\n previousState[matchKey].homeScore = homeScore;\n previousState[matchKey].awayScore = awayScore;\n previousState[matchKey].redCardCount = redCardCount;\n }\n}\n\n// Save updated match state back to flow context\nflow.set(\"matchesState\", previousState);\n\n// Only suppress output when absolutely no change occurred\nif (results.length === 0) {\n return null;\n}\n\n// Return detected changes\nmsg.payload = results;\nreturn msg;",
804
804
  "outputs": 1,
805
805
  "timeout": 0,
806
806
  "noerr": 0,
@@ -838,7 +838,7 @@
838
838
  "type": "function",
839
839
  "z": "8f04e1df3ff1c424",
840
840
  "name": "tracked teams",
841
- "func": "// -----------------------------------------------------------------------------\n// Node-RED Function Node: \"tracked teams config\"\n//\n// Purpose:\n// Set flow.trackedTeams — the list of teams that upcomingMatches /\n// matchesInPlay should process. Any event not involving one of these teams\n// is ignored.\n//\n// Edit the \"trackedTeamNames\" property on this subflow instance to\n// add/remove teams - just plain English names, no need to look up ESPN\n// IDs by hand. This function resolves each name against flow.eplTeams\n// (built by \"build EPL flow variable\") to find the matching id, and\n// stores the resolved {id, name, abbreviation} objects in\n// flow.trackedTeams for the parsing functions to use.\n//\n// Requires: flow.eplTeams must already be populated. Wire this function\n// AFTER \"build EPL flow variable\" in the startup sequence, e.g.:\n//\n// Inject (on deploy) -> HTTP Request (eng.1 teams) -> build EPL flow variable -> tracked teams config\n//\n// Matching against eplTeams.name is case-insensitive and trims whitespace,\n// but still requires an exact name match (e.g. \"Man City\" will NOT match\n// \"Manchester City\" - use the displayName ESPN returns, as seen in\n// flow.eplTeams).\n// -----------------------------------------------------------------------------\n\nlet trackedTeamNames = env.get(\"trackedTeamNames\") || \n[\n \"Arsenal\",\n \"manchester united\",\n \"manchester city\",\n \"liverpool\",\n \"everton\",\n \"chelsea\"\n];\n\n\n//node.warn(trackedTeamNames);\n\n// -----------------------------------------------------------------------------\n// The subflow property is typed as JSON, which means anything valid JSON\n// can technically be entered - a bare string, a number, an object - not\n// just an array. Guard against that here so a malformed property fails\n// loudly and obviously, rather than looping over the wrong shape of data\n// further down (e.g. iterating character-by-character over a string).\n// -----------------------------------------------------------------------------\n\nif (!Array.isArray(trackedTeamNames)) {\n node.warn('trackedTeamNames property is not a JSON array - got: ' + JSON.stringify(trackedTeamNames) + '. No teams will be tracked.');\n trackedTeamNames = [];\n}\n\nif (trackedTeamNames.length === 0) {\n node.warn(\"trackedTeamNames property is empty or unset - no teams will be tracked.\");\n}\n\nconst eplTeams = flow.get(\"eplTeams\") || [];\n\nif (eplTeams.length === 0) {\n node.warn(\"flow.eplTeams is empty - run 'build eplTeams' before this node.\");\n}\n\nconst teamsByName = {};\n\nfor (const team of eplTeams) {\n teamsByName[team.name.trim().toLowerCase()] = team;\n}\n\nconst trackedTeams = [];\n\nfor (const wantedName of trackedTeamNames) {\n\n const match = teamsByName[wantedName.trim().toLowerCase()];\n\n if (!match) {\n node.warn('Tracked team \"' + wantedName + '\" not found in flow.eplTeams - check spelling against ESPN\\'s displayName.');\n continue;\n }\n\n trackedTeams.push(match);\n}\n\nflow.set(\"trackedTeams\", trackedTeams);\n\nmsg.payload = trackedTeams;\n\nreturn msg;",
841
+ "func": "// -----------------------------------------------------------------------------\n// Node-RED Function Node: \"tracked teams config\"\n//\n// Purpose:\n// Set flow.trackedTeams — the list of teams that upcomingMatches /\n// matchesInPlay should process. Any event not involving one of these teams\n// is ignored.\n//\n// Edit the \"trackedTeamNames\" property on this subflow instance to\n// add/remove teams - just plain English names, no need to look up ESPN\n// IDs by hand. This function resolves each name against flow.eplTeams\n// (built by \"build EPL flow variable\") to find the matching id, and\n// stores the resolved {id, name, abbreviation} objects in\n// flow.trackedTeams for the parsing functions to use.\n//\n// Requires: flow.eplTeams must already be populated. Wire this function\n// AFTER \"build EPL flow variable\" in the startup sequence, e.g.:\n//\n// Inject (on deploy) -> HTTP Request (eng.1 teams) -> build EPL flow variable -> tracked teams config\n//\n// Matching against eplTeams.name is case-insensitive and trims whitespace,\n// -----------------------------------------------------------------------------\n\n\n// -----------------------------------------------------------------------------\n// Common alternative names/nicknames users are likely to type, mapped to\n// ESPN's own canonical displayName - not a hardcoded team ID, so this\n// stays correct regardless of exactly which teams are currently in\n// flow.eplTeams. Multiple aliases can point at the same canonical name\n// -----------------------------------------------------------------------------\nconst TEAM_ALIASES = {\n\n \"brighton\": \"Brighton & Hove Albion\",\n \"brighton and hove albion\": \"Brighton & Hove Albion\",\n \"brighton & hove albion\": \"Brighton & Hove Albion\",\n\n \"tottenham\": \"Tottenham Hotspur\",\n \"tottenham hotspur\": \"Tottenham Hotspur\",\n \"spurs\": \"Tottenham Hotspur\",\n\n \"westham\": \"West Ham United\",\n \"west ham\": \"West Ham United\",\n \"west ham united\": \"West Ham United\",\n\n \"leeds\": \"Leeds United\",\n \"leeds united\": \"Leeds United\",\n \n \"coventry\": \"Coventry City\",\n \n \"hull\": \"Hull City\",\n \n\n \"bournemouth\": \"AFC Bournemouth\",\n \"afc bournemouth\": \"AFC Bournemouth\",\n\n \"man united\": \"Manchester United\",\n \"man utd\": \"Manchester United\",\n \"manchester united\": \"Manchester United\",\n\n \"man city\": \"Manchester City\",\n \"manchester city\": \"Manchester City\",\n\n \"newcastle\": \"Newcastle United\",\n \"newcastle united\": \"Newcastle United\",\n \"newcastle utd\": \"Newcastle United\",\n\n \"forest\": \"Nottingham Forest\",\n \"nottingham forest\": \"Nottingham Forest\",\n \"nottm forest\": \"Nottingham Forest\",\n\n \"villa\": \"Aston Villa\",\n \"aston villa\": \"Aston Villa\",\n\n \"palace\": \"Crystal Palace\",\n \"crystal palace\": \"Crystal Palace\",\n\n \"leicester\": \"Leicester City\",\n \"leicester city\": \"Leicester City\",\n\n \"sheffield\": \"Sheffield United\",\n \"sheffield united\": \"Sheffield United\",\n \"sheffield utd\": \"Sheffield United\",\n\n \"west brom\": \"West Bromwich Albion\",\n \"wba\": \"West Bromwich Albion\",\n \"west bromwich albion\": \"West Bromwich Albion\",\n\n \"ipswich\": \"Ipswich Town\",\n \"ipswich town\": \"Ipswich Town\",\n\n \"norwich\": \"Norwich City\",\n \"norwich city\": \"Norwich City\",\n\n \"southampton\": \"Southampton\",\n\n \"lfc\": \"Liverpool\",\n\n \"luton\": \"Luton Town\",\n \"luton town\": \"Luton Town\"\n};\n\n\n\n\nlet trackedTeamNames = env.get(\"trackedTeamNames\") || \n[\n \"Arsenal\",\n \"manchester united\",\n \"manchester city\",\n \"liverpool\",\n \"everton\",\n \"chelsea\"\n];\n\n\n//node.warn(trackedTeamNames);\n\n// -----------------------------------------------------------------------------\n// The subflow property is typed as JSON, which means anything valid JSON\n// can technically be entered - a bare string, a number, an object - not\n// just an array. Guard against that here so a malformed property fails\n// loudly and obviously, rather than looping over the wrong shape of data\n// further down (e.g. iterating character-by-character over a string).\n// -----------------------------------------------------------------------------\n\nif (!Array.isArray(trackedTeamNames)) {\n node.warn('trackedTeamNames property is not a JSON array - got: ' + JSON.stringify(trackedTeamNames) + '. No teams will be tracked.');\n trackedTeamNames = [];\n}\n\nif (trackedTeamNames.length === 0) {\n node.warn(\"trackedTeamNames property is empty or unset - no teams will be tracked.\");\n}\n\nconst eplTeams = flow.get(\"eplTeams\") || [];\n\nif (eplTeams.length === 0) {\n node.warn(\"flow.eplTeams is empty - run 'build eplTeams' before this node.\");\n}\n\nconst teamsByName = {};\n\nfor (const team of eplTeams) {\n teamsByName[team.name.trim().toLowerCase()] = team;\n}\n\nconst trackedTeams = [];\n\nfor (const wantedName of trackedTeamNames) {\n\n // Resolve a known alias/nickname down to ESPN's canonical name before\n // matching - falls through to the original input unchanged if it\n // isn't a recognised alias, so this is purely additive and can't\n // break any name that already worked.\n const resolvedName = TEAM_ALIASES[wantedName.trim().toLowerCase()] || wantedName;\n\n const match = teamsByName[resolvedName.trim().toLowerCase()];\n\n if (!match) {\n node.warn('Tracked team \"' + wantedName + '\" not found');\n continue;\n }\n\n trackedTeams.push(match);\n}\n\nflow.set(\"trackedTeams\", trackedTeams);\n\nmsg.payload = trackedTeams;\n\nreturn msg;",
842
842
  "outputs": 1,
843
843
  "timeout": 0,
844
844
  "noerr": 0,
@@ -857,7 +857,7 @@
857
857
  "id": "6c4eb0f259fdf841",
858
858
  "type": "function",
859
859
  "z": "8f04e1df3ff1c424",
860
- "name": "build EPL temas variable(merge)",
860
+ "name": "build EPL teams variable(merge)",
861
861
  "func": "// -----------------------------------------------------------------------------\n// Node-RED Function Node: \"build EPL flow variable\"\n//\n// Purpose:\n// Set flow.eplTeams - the full list of teams (id/name/abbreviation),\n// used by \"tracked teams config\" to resolve the plain-English team names\n// in trackedTeamNames against ESPN's team IDs. This must run BEFORE\n// \"tracked teams config\" - see that function's header for the intended\n// wiring order.\n//\n// Source:\n// Reads msg.payload.sports[0].leagues[0].teams (the raw ESPN response\n// from a GET to a teams endpoint, as returned by the \"Call api\"\n// http request node feeding this function).\n//\n// MULTI-COMPETITION: this function now runs once per competition (e.g.\n// once for eng.1/EPL via the VPS, once for uefa.champions direct to\n// ESPN), both firing off the same trigger with no guaranteed order\n// between them. It used to rebuild flow.eplTeams from scratch every run\n// and overwrite it outright - fine when only one competition ever fed\n// it, but with two competitions racing on the same inject, whichever\n// response arrived SECOND silently wiped out the other's team list\n// entirely. Fixed by reading whatever's already stored and merging new\n// teams into it (keyed by team id, so a club appearing in both\n// competitions' lists - e.g. Arsenal in both EPL and Champions League -\n// doesn't end up duplicated), same read-merge-write pattern already used\n// by \"upcoming matches (advanced)\" and \"in play matches (live)\".\n//\n// Output: flow.eplTeams is an array of objects shaped like:\n//\n// { id: \"382\", name: \"Manchester City\", abbreviation: \"MCI\" }\n//\n// Entries missing an id or displayName are skipped rather than pushed\n// with blank fields, since a name-less/id-less entry can't be matched\n// against by \"tracked teams config\" anyway. Sorted alphabetically by\n// name purely for readability if you're eyeballing flow.eplTeams in the\n// context sidebar.\n// -----------------------------------------------------------------------------\n\nconst rawTeams = msg.payload?.sports?.[0]?.leagues?.[0]?.teams || [];\n\nlet eplTeams = flow.get(\"eplTeams\") || [];\n\nlet teamsById = {};\nfor (const t of eplTeams) {\n teamsById[t.id] = t;\n}\n\nfor (const entry of rawTeams) {\n const team = entry?.team;\n if (!team?.id || !team?.displayName) {\n continue;\n }\n teamsById[team.id] = {\n id: team.id,\n name: team.displayName,\n abbreviation: team.abbreviation || \"\"\n };\n}\n\neplTeams = Object.values(teamsById);\n\neplTeams.sort(function (a, b) {\n return a.name.localeCompare(b.name);\n});\n\nflow.set(\"eplTeams\", eplTeams);\nmsg.payload = eplTeams;\nreturn msg;",
862
862
  "outputs": 1,
863
863
  "timeout": 0,
@@ -1033,7 +1033,7 @@
1033
1033
  "type": "function",
1034
1034
  "z": "8f04e1df3ff1c424",
1035
1035
  "name": "purge disabled competitions",
1036
- "func": "// -----------------------------------------------------------------------------\n// Purge disabled competitions\n//\n// Runs once per deploy (wired into the same startup cascade the 24 hour\n// inject fires 2 seconds after deploying) - checking a checkbox in the\n// subflow's Properties dialog always requires a redeploy to take effect,\n// so this is a reliable \"something might have just changed, check now\"\n// trigger with no extra wiring or independent timer needed.\n//\n// For every OPTIONAL competition (one with its own on/off checkbox -\n// currently championsLeague, europaLeague, europaConferenceLeague; EPL\n// is NOT included here since it has no checkbox and is always on),\n// checks whether it's currently enabled. If it's NOT enabled, any\n// entries already sitting in flow.upcomingMatches or flow.matchesState\n// for that competition are removed.\n//\n// This is correct and safe in every case, not just \"just switched off\":\n// - just flipped true -> false: removes the now-stale entries (the\n// actual fix this was built for)\n// - was already false: finds nothing to remove, harmless no-op\n// - true (enabled): never touched, regardless of what else runs\n//\n// Matching is done on competitionKey (a short, keyword-derived\n// identifier like \"champions\" - see \"upcoming matches (advanced)\" and\n// \"in play matches (live)\" for how it's set), not the human-readable\n// \"competition\" display name, since the exact display wording isn't\n// guaranteed stable to match against.\n// -----------------------------------------------------------------------------\n\nconst OPTIONAL_COMPETITIONS = [\n { envVar: \"championsLeague\", key: \"champions\" },\n { envVar: \"europaLeague\", key: \"europa\" },\n { envVar: \"europaConferenceLeague\", key: \"europaConference\" }\n];\n\nconst disabledKeys = OPTIONAL_COMPETITIONS\n .filter(function (c) { return env.get(c.envVar) !== true; })\n .map(function (c) { return c.key; });\n\nif (disabledKeys.length > 0) {\n\n // --- Purge flow.upcomingMatches ---\n const upcomingMatches = flow.get(\"upcomingMatches\") || [];\n const upcomingBefore = upcomingMatches.length;\n\n const keptUpcoming = upcomingMatches.filter(function (match) {\n return !disabledKeys.includes(match.competitionKey);\n });\n\n flow.set(\"upcomingMatches\", keptUpcoming);\n\n // --- Purge flow.matchesState ---\n const matchesState = flow.get(\"matchesState\") || {};\n let matchesStateRemoved = 0;\n\n for (const matchKey of Object.keys(matchesState)) {\n if (disabledKeys.includes(matchesState[matchKey].competitionKey)) {\n delete matchesState[matchKey];\n matchesStateRemoved++;\n }\n }\n\n flow.set(\"matchesState\", matchesState);\n\n const upcomingRemoved = upcomingBefore - keptUpcoming.length;\n\n if (upcomingRemoved > 0 || matchesStateRemoved > 0) {\n node.warn(\n \"Purged disabled competitions (\" + disabledKeys.join(\", \") + \"): removed \" +\n upcomingRemoved + \" upcomingMatches entr\" + (upcomingRemoved === 1 ? \"y\" : \"ies\") + \", \" +\n matchesStateRemoved + \" matchesState entr\" + (matchesStateRemoved === 1 ? \"y\" : \"ies\") + \".\"\n );\n }\n}\n\nreturn msg;",
1036
+ "func": "// -----------------------------------------------------------------------------\n// Purge disabled competitions AND untracked teams\n//\n// Runs once per deploy (wired into the same startup cascade the 24 hour\n// inject fires 2 seconds after deploying, gated on msg.topic === \"run\n// once\") - checking a checkbox OR editing trackedTeamNames in the\n// subflow's Properties dialog always requires a redeploy to take effect,\n// so this is a reliable \"something might have just changed, check now\"\n// trigger with no extra wiring or independent timer needed. Placed AFTER\n// \"tracked teams config\" in the startup chain, so flow.trackedTeams is\n// already freshly rebuilt by the time this runs.\n//\n// TWO independent cleanup passes, both correct and safe in every case,\n// not just \"just switched off/removed\":\n// - just disabled/removed: purges the now-stale entries (the actual\n// fix this was built for)\n// - was already disabled/not tracked: finds nothing to remove,\n// harmless no-op\n// - enabled/tracked: never touched, regardless of what else runs\n//\n// COMPETITION PURGE: matches on competitionKey (a short, keyword-derived\n// identifier like \"champions\" - see \"upcoming matches (advanced)\" and\n// \"in play matches (live)\" for how it's set), not the human-readable\n// \"competition\" display name, since the exact display wording isn't\n// guaranteed stable to match against.\n//\n// TEAM PURGE: a team dropping out of trackedTeamNames doesn't remove its\n// existing upcomingMatches/matchesState entry on its own - the normal\n// per-poll processing in those two functions only ever ADDS/UPDATES\n// entries for currently-tracked teams, or REMOVES one on a \"post\" event\n// or the staleness sweep. Once a team is untracked, any match ESPN sends\n// for it is filtered out before ever reaching a point where it could be\n// cleaned up - the old entry is otherwise left showing as \"upcoming\" (or\n// stuck \"live\") indefinitely. Matches on homeTeamId/awayTeamId against\n// the just-rebuilt flow.trackedTeams.\n// -----------------------------------------------------------------------------\n\nconst OPTIONAL_COMPETITIONS = [\n { envVar: \"championsLeague\", key: \"champions\" },\n { envVar: \"europaLeague\", key: \"europa\" },\n { envVar: \"europaConferenceLeague\", key: \"europaConference\" }\n];\n\nconst disabledKeys = OPTIONAL_COMPETITIONS\n .filter(function (c) { return env.get(c.envVar) !== true; })\n .map(function (c) { return c.key; });\n\nconst trackedTeams = flow.get(\"trackedTeams\") || [];\nconst trackedTeamIds = new Set(trackedTeams.map(function (t) { return t.id; }));\n\n// --- Purge flow.upcomingMatches: drop anything in a disabled competition,\n// OR where neither team is currently tracked ---\nconst upcomingMatches = flow.get(\"upcomingMatches\") || [];\nconst upcomingBefore = upcomingMatches.length;\n\nconst keptUpcoming = upcomingMatches.filter(function (match) {\n if (disabledKeys.includes(match.competitionKey)) {\n return false;\n }\n if (!trackedTeamIds.has(match.homeTeamId) && !trackedTeamIds.has(match.awayTeamId)) {\n return false;\n }\n return true;\n});\n\nflow.set(\"upcomingMatches\", keptUpcoming);\n\n// --- Purge flow.matchesState: same two conditions ---\nconst matchesState = flow.get(\"matchesState\") || {};\nlet matchesStateRemoved = 0;\n\nfor (const matchKey of Object.keys(matchesState)) {\n const entry = matchesState[matchKey];\n\n const competitionDisabled = disabledKeys.includes(entry.competitionKey);\n const teamNotTracked = !trackedTeamIds.has(entry.homeTeamId) && !trackedTeamIds.has(entry.awayTeamId);\n\n if (competitionDisabled || teamNotTracked) {\n delete matchesState[matchKey];\n matchesStateRemoved++;\n }\n}\n\nflow.set(\"matchesState\", matchesState);\n\nconst upcomingRemoved = upcomingBefore - keptUpcoming.length;\n\n//if (upcomingRemoved > 0 || matchesStateRemoved > 0) {\n// node.warn(\n// \"Purge on deploy: removed \" +\n// upcomingRemoved + \" upcomingMatches entr\" + (upcomingRemoved === 1 ? \"y\" : \"ies\") + \", \" +\n// matchesStateRemoved + \" matchesState entr\" + (matchesStateRemoved === 1 ? \"y\" : \"ies\") +\n// \" (disabled competitions: \" + (disabledKeys.length > 0 ? disabledKeys.join(\", \") : \"none\") + \").\"\n// );\n//}\n\nreturn msg;",
1037
1037
  "outputs": 1,
1038
1038
  "timeout": 0,
1039
1039
  "noerr": 0,
@@ -1163,6 +1163,24 @@
1163
1163
  ],
1164
1164
  "icon": "node-red/arrow-in.svg"
1165
1165
  },
1166
+ {
1167
+ "id": "8c877362abe31602",
1168
+ "type": "function",
1169
+ "z": "8f04e1df3ff1c424",
1170
+ "name": "in play matches (old)",
1171
+ "func": "/***********************************************************************\n * Soccer Goal Detection Function\n *\n * Monitors live soccer matches and outputs an event when:\n * 1. A team score increases = goal (further classified as \"penalty\n * goal\" or \"own goal\" if ESPN flags it that way - see the\n * GOAL INFORMATION section below)\n * 2. A team score decreases = disallowed goal\n * 3. The red card count increases = red card\n * 4. The match reaches a recognised final status = full time (this\n * also stamps finishedAt on the stored match entry, which is what\n * the separate \"finished match cleanup\" function later uses to\n * remove it from flow.matchesState - see the FULL TIME block below)\n *\n * Match state is stored in flow context:\n *\n * flow.matchesState\n *\n * Each in-play match gets its own unique entry using a composite key.\n **********************************************************************/\n\n// -----------------------------------------------------------------------------\n// Shared constants, used across the staleness filter, status checks, and\n// team-data building below. Centralised here so there is one place to tune\n// them rather than repeating the same values inline.\n// -----------------------------------------------------------------------------\n\nconst STALE_AFTER_MS = 4 * 60 * 60 * 1000; // 4 hours past kickoff\nconst FINAL_STATUS_NAMES = [\"STATUS_FULL_TIME\", \"STATUS_FINAL_PEN\", \"STATUS_FINAL_AET\"];\nconst RED_CARD_TYPE_TEXT = \"Red Card\";\nconst FALLBACK_COLOR = \"FFFFFF\";\n\n// ESPN supplies match period as a number - mapped here to a display label\n// instead of an if/else chain:\n// 1 = 1st half, 2 = 2nd half, 3 = 1st half ET, 4 = 2nd half ET, 5 = penalties\nconst MATCH_PERIOD_LABELS = {\n 1: \"1stHalf\",\n 2: \"2ndHalf\",\n 3: \"1stHalf(ET)\",\n 4: \"2ndHalf(ET)\",\n 5: \"PENALTIES\"\n};\n\nlet results = [];\n\n// Retrieve previous match state from flow context\nlet previousState = flow.get(\"matchesState\") || {};\n\n\n// store player surname for awtrix messages created downstream \nfunction getPlayerSurname(shortName) {\n if (!shortName) {\n return \"\";\n }\n const separatorIndex = shortName.indexOf(\". \");\n return separatorIndex === -1 ? shortName : shortName.slice(separatorIndex + 2);\n}\n\n\n// -----------------------------------------------------------------------------\n// Staleness safety net.\n//\n// A stored match is normally removed when an event with\n// status.type.state === \"post\" and a recognised final status name\n// (see FINAL_STATUS_NAMES) is seen for its matchKey. That removal can\n// never fire if the match disappears from the feed before that \"post\"\n// event is ever received - for example, a polling gap (network outage,\n// flow restart) that spans the point where the match actually ends. The\n// entry is then stuck in flow.matchesState permanently, still showing\n// its last-known in-play score.\n//\n// As a fallback, every stored matchKey embeds its kickoff time as its\n// second \"|\"-separated segment (id|date|name). On each run, any entry\n// whose embedded kickoff time is more than STALE_AFTER_MS in the past is\n// dropped here, regardless of whether a qualifying \"post\" event was ever\n// received for it.\n// -----------------------------------------------------------------------------\n\nconst now = Date.now();\n\nfor (const key of Object.keys(previousState)) {\n\n const kickoffTime = key.split(\"|\")[1];\n const kickoff = new Date(kickoffTime).getTime();\n\n // Keep the entry if the embedded date is missing/unparseable rather\n // than guessing - malformed keys should surface elsewhere, not be\n // silently discarded by the staleness filter.\n if (isNaN(kickoff)) {\n continue;\n }\n\n if ((now - kickoff) >= STALE_AFTER_MS) {\n delete previousState[key];\n }\n}\n\n\n\n// Retrieve upcoming matches so we can copy across stored team IDs,\n// colours, and short names (homeTeamShort/awayTeamShort) for the\n// currently in-play match - see the lookup below.\nlet upcomingMatches = flow.get(\"upcomingMatches\") || [];\n\n\n// Load the tracked-teams list (set by \"tracked teams config\")\nconst trackedTeams = flow.get(\"trackedTeams\") || [];\nconst trackedTeamIds = new Set(trackedTeams.map(function (t) { return t.id; }));\n\n\n// Process every event in the incoming events array\nfor (const event of msg.payload.events || []) {\n\n /*******************************************************************\n * Create a unique identifier for this match.\n *\n * event.id alone was found to be shared between simultaneous games,\n * so the key is built from multiple values:\n *\n * - event.id or event.uid\n * - event.date\n * - event.name\n *\n * This prevents two live games overwriting each other in flow context.\n ******************************************************************/\n const matchKey = [\n event.id || event.uid || \"no-id\",\n event.date || \"no-date\",\n event.name || \"no-name\"\n ].join(\"|\");\n\n const state = event.status?.type?.state;\n const statusName = event.status?.type?.name;\n const completed = event.status?.type?.completed;\n\n /*******************************************************************\n * FULL TIME\n *\n * Fires once, when ESPN reports state === \"post\" with one of the\n * FINAL_STATUS_NAMES. Rather than deleting the matchesState entry\n * immediately, this stamps finishedAt = Date.now() on it and pushes\n * a \"full time\" event downstream (for the FT AWTRIX card / Alexa\n * announcement). The entry itself is left in flow.matchesState -\n * removal is handled later, and independently, by the separate\n * \"finished match cleanup\" function (60 minutes after finishedAt),\n * so this function doesn't need to know or care about that timing.\n ******************************************************************/\n if (state === \"post\" && FINAL_STATUS_NAMES.includes(statusName)) {\n\n\n // * previousState = matchesState variable\n // * matchKey = Unique identifier for the match\n // * create finishedMatch with all the stored values for that entry\n //\n // (diagram simplified to the fields that matter for illustrating\n // the lookup - the real stored entry also carries homeTeam/\n // awayTeam names, homeTeamColor/awayTeamColor, matchPeriod,\n // matchClock, redCardCount)\n /* previousState\n |\n │ Unique matchKey finishedMatch now adopts these fields on each iteration of the loop\n ├── \"401886533\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore }\n │ Unique matchKey \n ├── \"401900123\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore }\n │ Unique matchKey \n └── \"401900456\" ──────► { homeTeamId, awayTeamId, homeScore, awayScore } */\n\n\n\n const finishedMatch = previousState[matchKey];\n\n if (!finishedMatch) {\n continue;\n }\n\n if (finishedMatch.finishedAt) {\n continue;\n }\n\n\n finishedMatch.finishedAt = Date.now();\n\n results.push({\n matchKey,\n team: \"\",\n teamId: \"\",\n homeTeam: finishedMatch.homeTeam || \"\",\n awayTeam: finishedMatch.awayTeam || \"\",\n homeTeamId: finishedMatch.homeTeamId || \"\",\n awayTeamId: finishedMatch.awayTeamId || \"\",\n homeTeamColor: finishedMatch.homeTeamColor || \"\",\n awayTeamColor: finishedMatch.awayTeamColor || \"\",\n player: \"\",\n shirtNumber: \"\",\n event: \"full time\",\n matchClock: \"FT\",\n matchPeriod: \"FullTime\",\n homeScore: finishedMatch.homeScore,\n awayScore: finishedMatch.awayScore,\n mainColor: \"\"\n });\n\n continue;\n }\n \n\n /*******************************************************************\n * Only process matches currently in play.\n *\n * state === \"in\" && completed === false is sufficient on its own -\n * a calendar-date (\"today\") check was previously used here too, but\n * it wrongly cut off matches still live after midnight UTC (e.g. a\n * 22:00Z kickoff still in extra time at 00:30Z the next day), since\n * event.date is the fixed kickoff time and never changes while\n * \"today\" keeps advancing.\n *\n * Skip immediately rather than nesting the rest of the loop body\n * inside this check.\n ******************************************************************/\n if (state !== \"in\" || completed !== false) {\n continue;\n }\n\n // Skip matches involving neither tracked team - keeps matchesState limited\n // to matches we actually care about, same filter upcomingMatches applies.\n const homeIdRaw = String(event.competitions?.[0]?.competitors?.[0]?.team?.id || \"\");\n const awayIdRaw = String(event.competitions?.[0]?.competitors?.[1]?.team?.id || \"\");\n\n if (!trackedTeamIds.has(homeIdRaw) && !trackedTeamIds.has(awayIdRaw)) {\n continue;\n }\n\n /***************************************************************\n * Extract competitor objects.\n *\n * Expected structure:\n *\n * competitors[0] = home team\n * competitors[1] = away team\n **************************************************************/\n const homeCompetitor = event.competitions?.[0]?.competitors?.[0];\n const awayCompetitor = event.competitions?.[0]?.competitors?.[1];\n\n /***************************************************************\n * Validate competitor order.\n *\n * This protects against assigning goals to the wrong team if the\n * API ever returns the competitors in an unexpected order.\n **************************************************************/\n if (\n homeCompetitor?.homeAway !== \"home\" ||\n awayCompetitor?.homeAway !== \"away\"\n ) {\n node.warn(`Unexpected competitor order for match: ${event.name}`);\n continue;\n }\n\n /***************************************************************\n * Extract team names directly from the competitor team objects.\n *\n * Uses displayName (not name) so that team names here match what\n * the upcomingMatches function produces from the same ESPN\n * payload - the two previously used different fields, which\n * could disagree for teams where ESPN's \"name\" and\n * \"displayName\" differ (e.g. a shortened vs full team name).\n **************************************************************/\n const homeTeam = homeCompetitor?.team?.displayName || \"\";\n const awayTeam = awayCompetitor?.team?.displayName || \"\";\n\n /***************************************************************\n * Extract current scores.\n **************************************************************/\n const homeScore = Number(homeCompetitor?.score ?? 0);\n const awayScore = Number(awayCompetitor?.score ?? 0);\n\n /***************************************************************\n * Extract match clock.\n **************************************************************/\n const matchClock = event.status?.displayClock;\n\n /***************************************************************\n * Extract match period, mapped via MATCH_PERIOD_LABELS.\n **************************************************************/\n const matchPeriod = MATCH_PERIOD_LABELS[event.status?.period] || \"\";\n\n /***************************************************************\n * Find this match in the upcomingMatches flow variable.\n *\n * The upcomingMatches array stores team IDs, colours, and short\n * names against event.id:\n *\n * upcomingMatches[x].uniqueID\n * upcomingMatches[x].homeTeamId\n * upcomingMatches[x].awayTeamId\n * upcomingMatches[x].homeTeamShort\n * upcomingMatches[x].awayTeamShort\n * upcomingMatches[x].homeTeamColor\n * upcomingMatches[x].awayTeamColor\n * upcomingMatches[x].competitionKey\n **************************************************************/\n const upcomingMatch = upcomingMatches.find(function (match) {\n return String(match.uniqueID) === String(event.id);\n });\n\n const homeTeamId = upcomingMatch?.homeTeamId || \"\";\n const awayTeamId = upcomingMatch?.awayTeamId || \"\";\n const homeTeamColor = upcomingMatch?.homeTeamColor || FALLBACK_COLOR;\n const awayTeamColor = upcomingMatch?.awayTeamColor || FALLBACK_COLOR;\n const homeTeamShort = upcomingMatch?.homeTeamShort || \"\";\n const awayTeamShort = upcomingMatch?.awayTeamShort || \"\";\n const competitionKey = upcomingMatch?.competitionKey || \"\";\n \n\n /***************************************************************\n * RED CARD SECTION\n *\n * Count entries in the match details array where type.text\n * matches RED_CARD_TYPE_TEXT.\n **************************************************************/\n const details = event.competitions?.[0]?.details || [];\n\n const redCards = details.filter(function (detail) {\n return detail?.type?.text === RED_CARD_TYPE_TEXT;\n });\n\n const redCardCount = redCards.length; // stores the number of red card events found in the details[] array\n\n /***************************************************************\n * Retrieve previously stored score for this match.\n **************************************************************/\n const prev = previousState[matchKey];\n\n /***************************************************************\n * First time seeing this match. (FLOW VARIABLE)\n *\n * Store the current score as the baseline.\n * Do not output anything because no score change can be detected\n * until the next poll.\n **************************************************************/\n if (!prev) {\n previousState[matchKey] = {\n homeTeam,\n awayTeam,\n homeTeamId,\n awayTeamId,\n homeTeamColor,\n awayTeamColor,\n // Explicitly false unless upcomingMatches already reports\n // verified:true at this exact moment - see the self-heal\n // block below, which keeps retrying every poll until this\n // flips true from a genuinely resolved upcomingMatches entry.\n competitionKey,\n verified: !!upcomingMatch?.verified,\n homeScore,\n awayScore,\n matchPeriod,\n matchClock,\n redCardCount\n };\n\n continue;\n }\n\n\n // -----------------------------------------------------------------------------\n // matchClock and matchPeriod change continuously throughout the match, so\n // they're refreshed on every poll for an already-tracked match - not just\n // when a goal/red card is detected.\n // -----------------------------------------------------------------------------\n\n previousState[matchKey].matchClock = matchClock;\n previousState[matchKey].matchPeriod = matchPeriod;\n\n // Self-heal: homeTeamId/awayTeamId/homeTeamColor/awayTeamColor are\n // normally only written once, above, when this match's baseline is\n // first created - they don't change during a match, so there's no\n // need to keep rewriting them every poll. But if that first lookup\n // failed (upcomingMatches didn't have this match yet), those fields\n // would otherwise stay blank/FALLBACK_COLOR for the rest of the\n // match.\n //\n // IMPORTANT: this now defers to upcomingMatches' OWN \"verified\" flag,\n // not just \"did I get a non-blank homeTeamId\". upcomingMatches can\n // itself return a colour that's only a TEMPORARY placeholder (see\n // that function's own verified logic) if ESPN sent no colour data on\n // that particular poll - homeTeamId would already be non-blank at\n // that point (id/name/short are guaranteed complete before an\n // upcomingMatches entry is ever created at all), so the old\n // homeTeamId-only check would have copied over a placeholder colour\n // and then NEVER looked again, permanently disagreeing with\n // upcomingMatches even after it later resolved the real colour.\n // Checking upcomingMatch?.verified instead means this keeps retrying\n // every poll until upcomingMatches itself reports the colours are\n // genuinely resolved from real data, not just present.\n if (!previousState[matchKey].verified && upcomingMatch?.verified) {\n previousState[matchKey].homeTeamId = homeTeamId;\n previousState[matchKey].awayTeamId = awayTeamId;\n previousState[matchKey].homeTeamColor = homeTeamColor;\n previousState[matchKey].awayTeamColor = awayTeamColor;\n previousState[matchKey].competitionKey = competitionKey;\n previousState[matchKey].verified = true;\n }\n\n const prevHomeScore = Number(prev.homeScore ?? 0);\n const prevAwayScore = Number(prev.awayScore ?? 0);\n\n\n /***********************************************************************\n * MATCH EVENT PROCESSING\n *\n * Detect and output:\n *\n * - Goals\n * - Disallowed goals\n * - Red cards\n *\n * All event types use the same team data and output structure.\n ***********************************************************************/\n\n const prevRedCardCount = Number(prev.redCardCount ?? 0);\n\n let changed = false;\n\n /***********************************************************************\n * TEAM DATA\n *\n * Store the home and away team information in one reusable array.\n ***********************************************************************/\n\n const teams = [\n {\n team: homeTeam,\n teamId: homeTeamId,\n competitorTeamId: String(homeCompetitor?.team?.id || \"\"),\n currentScore: homeScore,\n previousScore: prevHomeScore,\n color: homeTeamColor\n },\n {\n team: awayTeam,\n teamId: awayTeamId,\n competitorTeamId: String(awayCompetitor?.team?.id || \"\"),\n currentScore: awayScore,\n previousScore: prevAwayScore,\n color: awayTeamColor\n }\n ];\n\n /***********************************************************************\n * Add a detected match event to the output.\n *\n * The event type, team and player are stored in separate fields.\n * Also carries playerSurname (via getPlayerSurname(), stripping the\n * \"X. \" prefix from ESPN's shortName) and homeTeamShort/awayTeamShort -\n * both added so downstream functions (queue dispatcher, team|score|\n * player) can label the OPPONENT team by short name for own-goal\n * display without a second lookup.\n ***********************************************************************/\n\n function addMatchEvent(teamData, eventType, eventClock, player = \"\", shirtNumber = \"\") {\n results.push({\n matchKey,\n team: teamData?.team || \"Unknown\",\n teamId: teamData?.teamId || \"\",\n homeTeamId,\n awayTeamId,\n homeTeamShort,\n awayTeamShort,\n shirtNumber,\n player,\n playerSurname: getPlayerSurname(player),\n event: eventType,\n matchClock: eventClock || matchClock || \"\",\n matchPeriod,\n homeScore,\n awayScore,\n mainColor: teamData?.color || FALLBACK_COLOR\n });\n\n changed = true;\n }\n\n\n\n\n /***********************************************************************\n * GOAL / DISALLOWED GOAL DETECTION\n ***********************************************************************/\n\n for (const teamData of teams) {\n\n if (teamData.currentScore > teamData.previousScore) {\n\n /*******************************************************************\n * GOAL INFORMATION\n *\n * Search the match details[] array for events where scoringPlay\n * is true and the event's team ID matches the team whose score\n * has increased.\n *\n * IMPORTANT: this used to filter on detail?.type?.text === \"Goal\",\n * but that was found to silently miss headers/own-goals/penalties,\n * since ESPN varies type.text by scoring method (e.g. \"Goal -\n * Header\", \"Penalty - Scored\", \"Own Goal\" - none of which is\n * literally \"Goal\"). detail?.scoringPlay === true is a flag ESPN\n * sets on every scoring detail regardless of how the goal was\n * scored, so that's what actually classifies something as a goal\n * now - not the type.text string.\n *\n * teamGoals[] - will contain only scoring-play event objects\n * belonging to the team whose score has increased.\n\n * details[] - assigned earlier from:\n * event.competitions[0].details\n\n *.filter() - built-in JavaScript function that automatically loops\n * through every object in details[] and returns a new array\n * containing only the objects that satisfy our conditions.\n * \n * function(detail) - detail is a temporary variable automatically\n * assigned to each object in details[], as filter() works through\n * the array one object at a time.\n * \n * detail?.scoringPlay === true\n * Checks whether the current event stored in 'detail' is a\n * scoring play (a goal, however it was scored - open play,\n * header, penalty, own goal, etc).\n * \n * String(detail?.team?.id || \"\") === teamData.competitorTeamId\n * Converts the current event's team id to a string and compares it\n * with the team whose score has increased.\n * Returns true if they match, otherwise false.\n * \n *******************************************************************/\n\n\n const teamGoals = details.filter(function (detail) {\n return (\n detail?.scoringPlay === true &&\n String(detail?.team?.id || \"\") === teamData.competitorTeamId\n );\n });\n\n\n /*******************************************************************\n * Select the latest goal event.\n *\n * teamGoals is an array of goal-event objects filtered from the\n * match details[] array above. Each object has the same structure\n * as an entry in details[] and contains properties such as:\n *\n * goal.team\n * goal.clock\n * goal.athletesInvolved\n * goal.type\n *\n * The newest goal-event object is selected here so its\n * properties can be accessed below.\n *******************************************************************/\n\n const goal = teamGoals[teamGoals.length - 1];\n\n /*******************************************************************\n * Extract scorer information from the selected goal object.\n *\n * The optional chaining (?.) safely traverses the goal object:\n *\n * goal\n * └── athletesInvolved[]\n * └── [0]\n * ├── shortName\n * └── jersey\n *\n * and:\n *\n * goal\n * └── clock\n * └── displayValue\n *\n * If any property is missing, an empty string is returned instead\n * of generating an error.\n *******************************************************************/\n\n const goalPlayer =\n goal?.athletesInvolved?.[0]?.shortName || \"\";\n\n const goalNumber =\n goal?.athletesInvolved?.[0]?.jersey || \"\";\n\n const goalClock =\n goal?.clock?.displayValue || matchClock || \"\";\n\n /*******************************************************************\n * Record the goal event.\n *\n * goalEventType stays \"goal\" for anything not specifically\n * flagged - headers, volleys, free-kicks, etc. Only the\n * penaltyKick/ownGoal flags on the matched goal object promote\n * it to \"penalty goal\"/\"own goal\", which downstream (queue\n * dispatcher / team|score|player) drives extra formatting (a\n * PEN tag, opponent short name for OG) while still looking like\n * a plain \"goal\" on the Alexa/AWTRIX-interrupt outputs.\n *******************************************************************/\n \n let goalEventType = \"goal\";\n\n if (goal?.penaltyKick === true) {\n goalEventType = \"penalty goal\";\n } else if (goal?.ownGoal === true) {\n goalEventType = \"own goal\";\n }\n\n addMatchEvent(\n teamData,\n goalEventType,\n goalClock,\n goalPlayer,\n goalNumber\n );\n\n } else if (teamData.currentScore < teamData.previousScore) {\n\n /*******************************************************************\n * A team's score has decreased.\n *\n * This is treated as a disallowed goal. No player information is\n * required because the scorer is no longer credited with a goal.\n *******************************************************************/\n\n addMatchEvent(\n teamData,\n \"disallowed goal\",\n matchClock\n );\n }\n}\n\n /***********************************************************************\n * RED CARD DETECTION\n *\n * Process only the latest red-card incident when the red-card count\n * has increased since the previous poll.\n ***********************************************************************/\n\n if (redCardCount > prevRedCardCount) {\n\n const redCard = redCards[redCards.length - 1];\n const redCardTeamId = String(redCard?.team?.id || \"\");\n\n const teamData = teams.find(function (team) {\n return team.competitorTeamId === redCardTeamId;\n });\n\n const redCardPlayer = redCard?.athletesInvolved?.[0]?.shortName || \"\";\n const redCardnumber = redCard?.athletesInvolved?.[0]?.jersey || \"\";\n const redCardClock = redCard?.clock?.displayValue || matchClock || \"\";\n\n addMatchEvent(teamData, \"red card\", redCardClock, redCardPlayer, redCardnumber);\n }\n\n /***************************************************************\n * Only update stored state when a score or red-card change\n * occurred. (FLOW VARIABLE)\n ***************************************************************/\n\n if (changed) {\n previousState[matchKey].homeScore = homeScore;\n previousState[matchKey].awayScore = awayScore;\n previousState[matchKey].redCardCount = redCardCount;\n }\n}\n\n// Save updated match state back to flow context\nflow.set(\"matchesState\", previousState);\n\n// Only suppress output when absolutely no change occurred\nif (results.length === 0) {\n return null;\n}\n\n// Return detected changes\nmsg.payload = results;\nreturn msg;",
1172
+ "outputs": 1,
1173
+ "timeout": 0,
1174
+ "noerr": 0,
1175
+ "initialize": "",
1176
+ "finalize": "",
1177
+ "libs": [],
1178
+ "x": 1380,
1179
+ "y": 860,
1180
+ "wires": [
1181
+ []
1182
+ ]
1183
+ },
1166
1184
  {
1167
1185
  "id": "ace5dd80ff9ac8e9",
1168
1186
  "type": "junction",