From cd9c335f29d4d38f74e8ee7eaebf38b6ac634799 Mon Sep 17 00:00:00 2001 From: bensynapse Date: Sun, 16 Aug 2026 14:14:28 +0300 Subject: [PATCH 1/2] tennisscores: new app - live tennis scores from the Live Tennis API Shows live matches (score, serving player) for a configurable tour, and the next scheduled fixtures when nothing is live. Manual refresh plus optional 15-minute auto refresh. Fetches over Bangle.http, so it needs an internet-enabled Gadgetbridge, and a (free) Live Tennis API key set in settings. Co-Authored-By: Claude Fable 5 --- apps/tennisscores/ChangeLog | 1 + apps/tennisscores/README.md | 45 ++++++ apps/tennisscores/app-icon.js | 1 + apps/tennisscores/app.js | 252 ++++++++++++++++++++++++++++++ apps/tennisscores/app.png | Bin 0 -> 291 bytes apps/tennisscores/metadata.json | 18 +++ apps/tennisscores/screenshot.png | Bin 0 -> 1677 bytes apps/tennisscores/screenshot2.png | Bin 0 -> 1868 bytes apps/tennisscores/settings.js | 75 +++++++++ 9 files changed, 392 insertions(+) create mode 100644 apps/tennisscores/ChangeLog create mode 100644 apps/tennisscores/README.md create mode 100644 apps/tennisscores/app-icon.js create mode 100644 apps/tennisscores/app.js create mode 100644 apps/tennisscores/app.png create mode 100644 apps/tennisscores/metadata.json create mode 100644 apps/tennisscores/screenshot.png create mode 100644 apps/tennisscores/screenshot2.png create mode 100644 apps/tennisscores/settings.js diff --git a/apps/tennisscores/ChangeLog b/apps/tennisscores/ChangeLog new file mode 100644 index 00000000000..5560f00bce7 --- /dev/null +++ b/apps/tennisscores/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/tennisscores/README.md b/apps/tennisscores/README.md new file mode 100644 index 00000000000..71a28ef9dd4 --- /dev/null +++ b/apps/tennisscores/README.md @@ -0,0 +1,45 @@ +# Tennis Scores + +Live tennis scores on your wrist, from the [Live Tennis API](https://livetennisapi.com). + +When matches are live it shows one match per page: tournament, round, both +players, games per set, current points and a dot next to the serving player. +When nothing is live it shows the next scheduled fixtures instead. + +## Usage + +Just install and configure the app. This needs an internet-enabled Gadgetbridge version. +Install one of the text input libraries to set the API key in the app settings. + +You need a (free) API key from [livetennisapi.com](https://livetennisapi.com). +Enter it in the app settings. + +## Controls + +* Swipe up/down (or press the button) to page through matches/fixtures +* Tap the screen to refresh + +## Settings + +* **Tour** - only show matches for one tour (ATP, WTA, Challenger, ITF, Juniors) or all +* **Auto refresh** - refresh automatically while the app is open (off by default) +* **Refresh every** - auto refresh interval, default 15 minutes +* **API key** - your Live Tennis API key (needs a text input library installed) + +## API usage and the free tier + +The free tier allows 30 requests/minute and 100 requests/day. Live scores, +players and fixtures are free; historical data is paid. + +Each refresh is one request (two when nothing is live, because the app then +also fetches the fixtures list). At the default 15 minute auto-refresh a full +day of continuous refreshing is about 96 requests, which fits the free tier's +100/day - and since the app only refreshes while it is open, real usage is +normally far below that. If you want a faster refresh interval you'll likely +need a paid key. + +## Creator + +[bensynapse](https://github.com/bensynapse) + +Disclosure: I maintain the Live Tennis API this app talks to. diff --git a/apps/tennisscores/app-icon.js b/apps/tennisscores/app-icon.js new file mode 100644 index 00000000000..df0d7c3577a --- /dev/null +++ b/apps/tennisscores/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwg85gMRAAQWRCoYACiAsQj3uAAQyPBwP/Cwfu9/xGJgWC//+C4YGBDBZFBCAQXG/3hJJJbC94RBIwYFDGBAuCFIYXEGoQwIRQhIEJoowHFwYXL8IXFgK5EIQhMEGARGJOQh8FJA5GEC5hIFC4pDDJYgXHLwpzEbgxgFC95eGLgZ2GMAoX/C/4X/d64XvMA5cCMQReIC44TBC4Z4FC4xIEC5cRC4hgFIYgXHCwZIGOYh4FIwpIGC5RGFJAQwDIQhME8JGGGAhyFAoguHGAjSGAwQuJGAURC5HxFxIYFbooWNJITdFAwJFKGIwAEFpgyHFiAA/AAQA=")) diff --git a/apps/tennisscores/app.js b/apps/tennisscores/app.js new file mode 100644 index 00000000000..a1e01148886 --- /dev/null +++ b/apps/tennisscores/app.js @@ -0,0 +1,252 @@ +const FILE = "tennisscores.json"; +const BASE = "https://api.livetennisapi.com/api/public/v1"; + +let settings = Object.assign({ + apikey: "", + tour: "", // "" = all tours, or atp/wta/challenger/itf/juniors + auto: false, // periodic refresh while the app is open + refresh: 15 // minutes +}, require("Storage").readJSON(FILE, true) || {}); + +let state = "loading"; // loading | live | fixtures | nokey | error +let matches = []; // slimmed live matches +let fixtures = []; // slimmed upcoming fixtures +let index = 0; // current page +let lastError = ""; +let updatedAt = null; +let autoTimer = null; +let loading = false; + +function tourQuery() { + return settings.tour ? "&tour=" + settings.tour : ""; +} + +function apiGet(path) { + if (!Bangle.http) + return Promise.reject(/*LANG*/"Gadgetbridge required"); + return Bangle.http(BASE + path, { + timeout: 15000, + headers: {Authorization: "Bearer " + settings.apikey} + }).then(ev => JSON.parse(ev.resp)); +} + +function trunc(s, n) { + if (!s) return ""; + return s.length > n ? s.substr(0, n - 1) + "." : s; +} + +function pad2(n) { + return (n < 10 ? "0" : "") + n; +} + +function fmtTime(iso) { + if (!iso) return ""; + let d = new Date(iso); + return pad2(d.getDate()) + "/" + pad2(d.getMonth() + 1) + " " + + pad2(d.getHours()) + ":" + pad2(d.getMinutes()); +} + +// keep only what we draw - the full API objects are too big to hold many of +function slimMatch(m) { + let s = m.score; + let p = m.players || {}; + return { + tournament: m.tournament || "", + round: m.round || "", + doubles: !!m.is_doubles, + n1: (p.p1 && p.p1.name) || "?", + n2: (p.p2 && p.p2.name) || "?", + // score.games is [games_p1, games_p2], each a per-set list + g1: (s && s.games && s.games[0]) || [], + g2: (s && s.games && s.games[1]) || [], + // in-game points as tennis strings; entries can be null + pt1: (s && s.points && s.points[0]) || null, + pt2: (s && s.points && s.points[1]) || null, + server: (s && s.server) || null, // 1, 2 or null + tiebreak: !!(s && s.is_tiebreak) + }; +} + +function slimFixture(f) { + return { + tournament: f.tournament || "", + round: f.round || "", + n1: f.player1_name || "?", + n2: f.player2_name || "?", + date: f.event_date || null, + time: f.start_time || null // null until the order of play assigns one + }; +} + +function scheduleAuto() { + if (autoTimer) clearTimeout(autoTimer); + autoTimer = null; + if (settings.auto) + autoTimer = setTimeout(() => refresh(), settings.refresh * 60000); +} + +function refresh() { + if (loading) return; + if (!settings.apikey) { + state = "nokey"; + draw(); + return; + } + loading = true; + drawUpdating(); + apiGet("/matches?status=live" + tourQuery() + "&limit=10").then(r => { + if (!r || !Array.isArray(r.data)) throw new Error(/*LANG*/"Bad response"); + if (r.data.length) { + matches = r.data.map(slimMatch); + state = "live"; + return; + } + // nothing live - show the next scheduled fixtures instead + return apiGet("/fixtures?limit=8" + tourQuery()).then(r2 => { + if (!r2 || !Array.isArray(r2.data)) throw new Error(/*LANG*/"Bad response"); + fixtures = r2.data.map(slimFixture); + state = "fixtures"; + }); + }).then(() => { + loading = false; + index = 0; + updatedAt = new Date(); + scheduleAuto(); + draw(); + }).catch(e => { + loading = false; + lastError = ("" + ((e && e.message) || e)).substr(0, 60); + state = "error"; + scheduleAuto(); + draw(); + }); +} + +function pageCount() { + if (state === "live") return matches.length; + if (state === "fixtures") return Math.ceil(fixtures.length / 2); + return 1; +} + +function drawUpdating() { + let R = Bangle.appRect; + g.reset().clearRect(R.x, R.y2 - 10, R.x2, R.y2); + g.setFont("6x8").setFontAlign(0, 1); + g.drawString(/*LANG*/"Updating...", (R.x + R.x2) / 2, R.y2); +} + +function drawFooter(R) { + g.setFont("6x8").setFontAlign(0, 1); + let s = (pageCount() > 1 ? (index + 1) + "/" + pageCount() + " " : "") + + (updatedAt ? /*LANG*/"upd " + pad2(updatedAt.getHours()) + ":" + pad2(updatedAt.getMinutes()) : "") + + " " + /*LANG*/"tap=reload"; + g.drawString(s, (R.x + R.x2) / 2, R.y2); +} + +function drawScoreRow(games, points, serving, y, R) { + g.setFont("6x8", 2).setFontAlign(-1, -1); + // show at most the last 3 sets so BO5 still fits on screen + let shown = games.length > 3 ? games.slice(games.length - 3) : games; + let s = shown.join(" "); + if (points !== null && points !== undefined) s += " " + points; + g.drawString(s, R.x + 24, y); + if (serving) g.fillCircle(R.x + 12, y + 7, 4); +} + +function drawLive(R) { + let m = matches[index]; + if (!m) return; + let y = R.y + 2; + g.setFont("6x8").setFontAlign(-1, -1); + g.drawString(trunc(m.tournament, 29), R.x + 2, y); + y += 10; + g.drawString(trunc(m.round + (m.doubles ? /*LANG*/" (doubles)" : ""), 29), R.x + 2, y); + y += 14; + g.setFont("12x20").setFontAlign(-1, -1); + g.drawString(trunc(m.n1, 14), R.x + 2, y); + y += 22; + drawScoreRow(m.g1, m.pt1, m.server === 1, y, R); + y += 20; + g.setFont("12x20").setFontAlign(-1, -1); + g.drawString(trunc(m.n2, 14), R.x + 2, y); + y += 22; + drawScoreRow(m.g2, m.pt2, m.server === 2, y, R); + y += 20; + if (m.tiebreak) { + g.setFont("6x8").setFontAlign(-1, -1); + g.drawString(/*LANG*/"Tiebreak", R.x + 24, y); + } + drawFooter(R); +} + +function drawFixtures(R) { + let y = R.y + 2; + g.setFont("6x8", 2).setFontAlign(0, -1); + g.drawString(/*LANG*/"Nothing live", (R.x + R.x2) / 2, y); + y += 20; + g.setFont("6x8").setFontAlign(0, -1); + g.drawString(settings.tour ? settings.tour.toUpperCase() + /*LANG*/" - next up:" : /*LANG*/"Next up:", (R.x + R.x2) / 2, y); + y += 12; + let shown = fixtures.slice(index * 2, index * 2 + 2); + if (!shown.length) { + g.setFont("6x8", 2).setFontAlign(0, -1); + g.drawString(/*LANG*/"No fixtures", (R.x + R.x2) / 2, y + 10); + } + shown.forEach(f => { + g.setFont("6x8").setFontAlign(-1, -1); + let when = f.time ? fmtTime(f.time) : + (f.date ? f.date.substr(8, 2) + "/" + f.date.substr(5, 2) + " " : "") + /*LANG*/"TBA"; + g.drawString(trunc(when + " " + f.tournament, 29), R.x + 2, y); + y += 10; + g.setFont("6x8", 2).setFontAlign(-1, -1); + g.drawString(trunc(f.n1, 14), R.x + 2, y); + y += 16; + g.drawString(trunc(f.n2, 14), R.x + 2, y); + y += 20; + }); + drawFooter(R); +} + +function drawCentered(lines, R) { + let y = (R.y + R.y2) / 2 - lines.length * 8; + g.setFont("6x8", 2).setFontAlign(0, -1); + lines.forEach(l => { + g.drawString(l, (R.x + R.x2) / 2, y); + y += 18; + }); +} + +function draw() { + let R = Bangle.appRect; + g.reset().clearRect(R); + if (state === "live") drawLive(R); + else if (state === "fixtures") drawFixtures(R); + else if (state === "nokey") { + drawCentered([/*LANG*/"No API key", /*LANG*/"Set one in", /*LANG*/"Settings"], R); + g.setFont("6x8").setFontAlign(0, 1); + g.drawString("livetennisapi.com", (R.x + R.x2) / 2, R.y2); + } else if (state === "error") { + drawCentered([/*LANG*/"Error"], R); + g.setFont("6x8").setFontAlign(0, -1); + g.drawString(g.wrapString(lastError, R.w - 8).slice(0, 3).join("\n"), (R.x + R.x2) / 2, (R.y + R.y2) / 2 + 4); + drawFooter(R); + } else { + drawCentered([/*LANG*/"Loading..."], R); + } +} + +Bangle.setUI({mode: "updown"}, dir => { + if (!dir) { + refresh(); // tap / button = manual refresh + return; + } + let n = pageCount(); + if (n < 2) return; + index = (index + dir + n) % n; + draw(); +}); + +Bangle.loadWidgets(); +draw(); +Bangle.drawWidgets(); +refresh(); diff --git a/apps/tennisscores/app.png b/apps/tennisscores/app.png new file mode 100644 index 0000000000000000000000000000000000000000..8851e29a2c0eccb488a976d56a434bf3a7a17f17 GIT binary patch literal 291 zcmV+;0o?wHP)mv|mUlN`lYEAzbbx{vvS?8v^#SNJLuSuU~6SY5I0BM}|J% zwK;~vY$qP0r~n|S)F1VvqlqB1vqZIq%qP0=5q p=+IE~$Y{Eh6n$co_$@9b2fG;%}MLiD_n;mW#pK^^foQzV|)f`<>@J&-naekG~7+CErkUIq@$oBb!~*GQasg zU`#ORYuo2Np89iBw-W5@j@W`?-l}+BT=gpU)FQ(6sTj@;3V|vRv{YBx?PcLI-Wdp& zt*E(lnicoLS}#I*1p4CB{YLA76C)$>_&1xk>9lM8Qr%%@B1w0@VdpWhplv5${-X|g z+nqRHMik)dDZsHosg>l-bk5QE^Xj?J4`MbaO+T`BFy2t}y(h_Q-o}sm;H0bf29^cG z`rNz0(3KaU=Ors!9s~ zKCW)rx+JX&)4ahMfDLjcufq^X6eeW*NA}d@+|~YtW&d{o;rr=PZmtEwa+2O=R^QC} z@avqyS-RSzl{@-7NxbZerp;N321|9}B(t@qdh()OUU^smN*>3NKbryIbXB_$yWZr1 zaNO<_VYnW$SOb^T5)qC>#uvndwsPjuHL{vTZ7al%J>#1K`i*j2@)3lMvsX&W8Bhpp zH4r&X<=r$e@13%|ZS!vFfwjceX+5~4_jD|LL@+km zA$|tsQ?V|-jm+TOD({4<;)VF?Ng1%eS;6n&sFOLzEd6FX->vYo0p zXsO6XGppw^-Qh-T5P>tHMjb!$B$vHdQiBdjfy;dOr#cHiy_kQ}?RvY1wA6!$+Qbd$ zXs+TB9L0AnPP387lhI=a06GM+L;uGd6lO94O3eF`kZdrq8cNu%0%Sc6C6~ac1sl!0 z#?M~_XRju0sK>n^zQSB2uFzvk`S;)LR~qtO@~dCX<2q&=7g<>)j&lT)7iONUxC_!b z+l{Wo*v2zh(J0L{_Dbqn3vAa7)}D;WL-1r9O3qA0qx?F0H!_=1+Jf?Ujh;_L>|>~~ zuTKEvy-8Ka#AyyFQ61=>v_rT6OuNL}Re~;+TGkFC?4h{k)qQ&?zm|FV1EL+b?^rCI zno+ovBXbGLQ{rNa)ll}ij38d*0O+UmTp8bu(CQ5Xy#K+WWRKsJWxHH*B-Q<@NUdzb zobd;CcOru^e(LOcU#+q#vx2oR4Z9roX4E)n<5j2T78A8&_%9WAG!7wbDM~!iJu&~X zcdbj3HbJ|bKF>F57=(uWkN}$L2G>C#fygcY?|Q(^WVg_z)LkGxBOZ6A4Fla z85Cx>VaG&gT45adM(R}BzK^M-1?b_P&suo%=qrk(M7VlC$RUu=f(~2>E?Tt`AWoL+ zkSgZ_**qhQFs{@tGpFRBzGYgX8(F{Gs%)V1F{kc*u20%ms>~<9R!$Z oPFT;lH}u~hfo`72Uc5)&$o?ez!7twPnr{XceMgRZ+ literal 0 HcmV?d00001 diff --git a/apps/tennisscores/screenshot2.png b/apps/tennisscores/screenshot2.png new file mode 100644 index 0000000000000000000000000000000000000000..92da7dc8ff3a41726fcc0c9699fbe04dc939f2db GIT binary patch literal 1868 zcmZ`)c{tST9)D-XEY^mxWvQg(o^*zUxt76XNse8{ekR>)#c+@<`jtdoLLDjxlY|mx z9J`s${G1#myH1v|O;5U(L*t%o;c$NA{(b*=-}iaG&v$)3-_Pg0?dohVE2Sm{0FZS! zW8;pz9PuIGkel5+69@pF z*HNQVKd-e-!{PUKDU4S*>IiP_q4Irykx}MWNeV+b=mK=qHK|7w+%KIY#0qHJ=7b{+y^G4ww+SBjK5d3ZA{Hy)qg3eYeTG(0Up-!=~8?XoTg`G`jTmq;%$9Ync6jbD8~ z$ViC229*R#!m6~EP^h^Wk7IyH+2zGk-E(boD+xbIxB>emj-j8+k`n>ogzi9F-Mf;U zh~T3Q>_8R~XiLBZXmHU=7Pag?7tF6b4T|KJ*J*FocT0Azr7Vq?2M{?E8FpwmN9a#h z@0zqWf0xWhX{_D?!d|D$>6cR!Wr4pa6swBjNFHI1>T3bYRbo8;{*034m-CalL!HVNneR5_9G3){ z2fTxFe7mi=*Au+=^)&2X=)fpGnUc*oYy~v7!%f4?bedRb2B7X2R;drhu?I-6V6;9s z{Y+7~*jrj}LSHlGtHEwx8;2jzUC&1Epj4q8kkIt&lV;Jn9EB7hR}biH_84wHk_Pkv zQTZ%PTdwV4M361MPJ#iZ)ET0% zN4ijbMf8)(-iEKsNf+q`S`(FnKox2Rs-@0oh6H@nuz*L*Elx#Nh-V0M@z44K&vv?L zLMYWSCjvSC?OfQm?DFopbQ$(a$b*m{xq1Lunq;ptD?>32G5z7B!{aHx|FK>D0~V<# z&0xlNd<)oW{^w$Ztbm08BMM@sl7!*2bVDsKu17GJkIawZygn3}Z&t1X4~MZrbIU4g ze(tU>l0+dLU|~X>f_Nthki57%LNQ@E(pg}`VoF8jhpsZU&Ng0`JK9fqnHP5=V`y-J zoIzw4Y$>Fq2CO6+!tbBFcbq0L`%C6uW>Wb&n>?;$sl@@!nm{#egk>&ws0Yze8KJ9! zEmF!$R^LcxXe`d9foBZaVu$AExu;$Mb~e$l+rkE0~TSd&Rh-eOs3IRXc= z3*TEe)wBolOgj4t5BN=9N45aNsg#t*${-}lRe^Pb3(1@R9CsR+h{Mim%I$?+8*hNJvH`EwEkOCUQ;F|pBsE710zSmS|b(M*eRKndILMCQF) zyArhx<~6dzzTX2Ld~><#vD?QD+>mxaSwQY7zQs`MOUXPerzzwvdeoh&kXtPbiKEou zq6^U@0?#sfRmE(c)kh!Y;Xem&d{f5{xjb1DAtY72)@7xCvXOt>w~0p?HXU%t3i^cn OlEA^%*`}7pxbZhJ13Z8L literal 0 HcmV?d00001 diff --git a/apps/tennisscores/settings.js b/apps/tennisscores/settings.js new file mode 100644 index 00000000000..63655f9e1e1 --- /dev/null +++ b/apps/tennisscores/settings.js @@ -0,0 +1,75 @@ +(function(back) { + var FILE = "tennisscores.json"; + var TOURS = ["", "atp", "wta", "challenger", "itf", "juniors"]; + var TOUR_NAMES = [/*LANG*/"All", "ATP", "WTA", "Challenger", "ITF", "Juniors"]; + var settings; + + function readSettings() { + settings = Object.assign({ + apikey: "", + tour: "", + auto: false, + refresh: 15 + }, require("Storage").readJSON(FILE, true) || {}); + } + + function writeSettings(key, value) { + var s = require("Storage").readJSON(FILE, true) || {}; + s[key] = value; + require("Storage").writeJSON(FILE, s); + readSettings(); + } + + readSettings(); + + function buildMainMenu() { + var mainmenu = { + "": {"title": /*LANG*/"Tennis Scores"}, + "< Back": back, + /*LANG*/"Tour": { + value: Math.max(0, TOURS.indexOf(settings.tour)), + min: 0, + max: TOURS.length - 1, + format: v => TOUR_NAMES[v], + onchange: v => { + writeSettings("tour", TOURS[v]); + } + }, + /*LANG*/"Auto refresh": { + value: !!settings.auto, + onchange: v => { + writeSettings("auto", v); + } + }, + /*LANG*/"Refresh every": { + value: settings.refresh, + min: 5, + max: 120, + step: 5, + format: v => v + "min", + onchange: v => { + writeSettings("refresh", v); + } + } + }; + + mainmenu[/*LANG*/"API key"] = function() { + if (require("textinput")) { + require("textinput").input({text: settings.apikey}).then(result => { + if (result != "") { + writeSettings("apikey", result); + } + E.showMenu(buildMainMenu()); + }); + } else { + E.showAlert(/*LANG*/"Install a text input lib").then(() => { + E.showMenu(buildMainMenu()); + }); + } + }; + + return mainmenu; + } + + E.showMenu(buildMainMenu()); +}) From 07b0872d78e9721defee0295729a257326b59f0d Mon Sep 17 00:00:00 2001 From: bensynapse Date: Sun, 16 Aug 2026 19:45:36 +0300 Subject: [PATCH 2/2] tennisscores: add App Loader interface for entering the API key Suggested by @thyttan - the watch keyboard may not even have all the characters an API key needs. interface.html (modeled on owmweather's) loads tennisscores.json, lets you paste the key and pick a tour from the browser, and writes it back. On-watch settings entry stays as a fallback. Co-Authored-By: Claude Fable 5 --- apps/tennisscores/README.md | 6 ++- apps/tennisscores/interface.html | 74 ++++++++++++++++++++++++++++++++ apps/tennisscores/metadata.json | 1 + 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/tennisscores/interface.html diff --git a/apps/tennisscores/README.md b/apps/tennisscores/README.md index 71a28ef9dd4..2e2fbe2aef4 100644 --- a/apps/tennisscores/README.md +++ b/apps/tennisscores/README.md @@ -9,10 +9,12 @@ When nothing is live it shows the next scheduled fixtures instead. ## Usage Just install and configure the app. This needs an internet-enabled Gadgetbridge version. -Install one of the text input libraries to set the API key in the app settings. You need a (free) API key from [livetennisapi.com](https://livetennisapi.com). -Enter it in the app settings. +The easiest way to enter it is the app's web interface in the App Loader, +which lets you paste the key and pick a tour from the browser. Alternatively +install one of the text input libraries and set the key on the watch in the +app settings. ## Controls diff --git a/apps/tennisscores/interface.html b/apps/tennisscores/interface.html new file mode 100644 index 00000000000..ee6c33d5475 --- /dev/null +++ b/apps/tennisscores/interface.html @@ -0,0 +1,74 @@ + + + + + +

Tennis Scores settings

+

+
+ +

+

+
+ +

+

+ +

Where to get your personal API key?

+

Go to https://livetennisapi.com and sign up for a free account.
+ After registration you can log in and obtain your personal API key.

+ + + + + + diff --git a/apps/tennisscores/metadata.json b/apps/tennisscores/metadata.json index 5e48a61ba83..46b420141ca 100644 --- a/apps/tennisscores/metadata.json +++ b/apps/tennisscores/metadata.json @@ -8,6 +8,7 @@ "tags": "sport,http", "supports" : ["BANGLEJS2"], "readme": "README.md", + "interface": "interface.html", "screenshots" : [ {"url":"screenshot.png"}, {"url":"screenshot2.png"} ], "storage": [ {"name":"tennisscores.app.js","url":"app.js"},