电视不是用来抢的,是用来挣的:答题积分与 HA 联动

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

2026-11-16 07:33:01 0点赞 0收藏 1评论

先看一段 95 秒实拍:孩子按遥控器答题,服务端当场判分,答对是金色 +1、答错是粉红发光的 −1 从画面中央飘起来,同时语音把题目、选项、判分结果和讲解一起念出来。这就是这套系统每天在客厅里跑的样子。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动电视不是用来抢的,是用来挣的:答题积分与 HA 联动

下面从「为什么单纯限时不管用」讲起,把电视端 APK、答题判分、积分账本、Home Assistant 联动和群晖 Chat 这五块是怎么拼起来的写清楚。

一、起因:一场关于遥控器的持久战

家里有台小米电视。它本身没问题,问题在于它太好用了 —— 打开就有动画片,不需要任何成本。 于是每天的对话都变成同一句话的变体:「看多久了?」「就一会儿。」「一会儿是多久?」

单纯限时是行不通的。定时器可以被绕过,关机可以再开,说好二十分钟最后总是四十分钟。 真正缺的不是一个开关,而是一套让孩子自己认账的规则:

  • 电视时间要有明确的价格,而不是家长的临时裁决;

  • 价格要能通过自己的劳动支付 —— 做题、写作业、练字、阅读;

  • 挣和花的过程要可查证,避免「我明明挣了」和「你根本没做」的争执。

先有 1.0,才有 2.0

这里最容易记错的一点是:把电视「按住」这件事,1.0 就做完了。 第一版已经装了「自律小达人」APK、定下硬约束 —— 只要电视时间没在计时,就必须待在里面; 也有那段每 5 秒跑一次、通过 HA 把切走的电视拉回 APK 的巡检。 积分兑换电视时间、倒计时、Chat 一句话加分与关电视,也都跑在这一版里。 2.0 一行没动这套控制链路,直接沿用。

1.0 唯一的短板在「分怎么来」:孩子完成一项任务,家长在群晖 Chat 里发一句 完成 作业 或 +10分,服务端解析正则后把分加上。 跑了一阵子,弊端全压在「家长」这一个环节上:

  • 家长必须在场、必须记得发 —— 漏发一次,孩子就白做一次,规则立刻失去可信度;

  • 「做没做完」靠口头判定,加分变成一次谈判,屏幕上的数字不再客观;

  • 孩子拿不到即时反馈,攒分离他太远,新鲜感撑不过一周。

2.0 换掉的是最核心的那一环,却把整套逻辑翻了过来:把「家长手动加分」换成「孩子自己答题挣分」。 答题由服务端判分、即时到账,家长从「每笔都要签字的裁判」退回到「偶尔奖励的人」。 围绕这条新链路又长出题库、语音、防盲选扣分、加减分动画、每日总结和积分商城; 任务、习惯、专注这些渠道都保留,但不再是唯一入口;积分的去路也从「换电视时间」扩到了整个商城。 唯独电视侧那套锁定与巡检,是 1.0 底座,2.0 原样沿用。 下面写的是 2.0 全貌,版本归属见下表。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

这套规则最后落成了一个跑在电视上的 Web 应用,加上一层把电视管起来的家庭自动化。 现在的 2.0 已经不是玩具:11975 道题、五门学科、语音念题与讲解、带解析、带错题本、带防作弊,一个 8 Tab 的家长手机端,以及一个能兑礼品的积分商城。

11975题库题量 5学科覆盖 1:1积分兑分钟 5sAPK 跳回巡检

二、整体结构:MITV 管规则,HA 管电视

整个系统的骨架很朴素:规则只有一个中心。电视端 APK 与家长手机端是两套前端, 都只负责显示与点击;Home Assistant 只处理设备动作; 积分、计时、判分、题库、语音、商城、截图推送全部收在 mitv-server 里, 两边前端读写的是同一个 SQLite 账本。 这决定了它的关键性质 —— 前端改不了分数,HA 也改不了分数。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

整体结构:mitv-server 是唯一的规则与状态中心;电视端是装了 APK 的 WebView, 家长手机端是 8 个 Tab 的独立 SPA,二者共用同一账本;Home Assistant 只负责设备侧。 其中 APK 常驻、硬约束与 5 秒巡检是 1.0 底座,2.0 沿用;新增的都在挣分与后台这一侧。

三、电视端:装一个 APK,并让它赖着不走

改造思路不是刷机,而是降级使用 —— 把智能电视当成一块带遥控器的浏览器。 这套做法是 1.0 就落地、2.0 原样沿用的:电视上装了一个 804KB 的 tv.apk(包名 com.mitv.dashboard), 本质是全屏 WebView 壳,启动即指向内网地址,剩下的全部交给网页。 这样做的好处是立刻获得三个能力:

  • 界面完全可控,字号按「三米外能看清」设计(clamp() 配合 vw 自适应);

  • 改版不需要动电视,改服务器上的文件、刷新即可;

  • 电视端没有本地存储、没有离线逻辑,刷新即真相,不存在本地缓存篡改分数。

服务端是一个跑在群晖上的 Express 容器,源码在 /volume1/docker/mitv/mitv-docker/server/index.js, 数据库用 SQLite(WAL 模式),单文件、免维护、方便备份。 电视端每 5 秒轮询一次状态:

// 电视端 tv.html —— 全部状态来自服务端,不做本地计算 const API = `http://${location.hostname}:3001` async function loadData () { const stateRes = await fetch(`${API}/api/app/state`) const tvRes = await fetch(`${API}/api/ha/state/tv`) const nRes = await fetch(`${API}/api/tv/notifications`) // ... } setInterval(loadData, 5000)

这个选择后来被证明是对的。所有「孩子能不能多看一眼」的判断,都在那台 NAS 上完成, 电视重启、断网重连、换一台设备打开,看到的都是同一个账本。

但「装了」不等于「一直在」。孩子按一下主页键就能回桌面,所以 1.0 一开始就定了一条硬约束: 只要电视时间没在计时,就必须待在自律小达人里。服务每 5 秒问一次 HA 当前所在应用,不在就把 APK 切回来(第七节有代码)。APK 因此从一个「入口」变成了「常驻状态」。 这条约束和这段巡检都是 1.0 的实现,2.0 没有改动。

四、积分怎么「挣」:一台跑在电视上的答题机

题目来自一份 3.2MB 的 JSON 题库,11975 道单选题,全部为人教版二年级水平,每道题都带讲解:

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

题库从最初的 1158 道扩到 11975 道,其中数学计算题占了大头。原因很实际: 这类题目可以按参数批量生成、解析也能自动写清楚,扩充成本最低; 而语文、常识这类需要人工校对的题,增长就慢得多。题库热重载生效, 改完 JSON 不用重启服务,日志里会打印新的题量。

难度分布为易 1308 / 中 7590 / 难 3077。计分刻意做得很简单:答对 1 分,答错扣 1 分,总分不为负。 不按难度加权,也不做连对加成 —— 对一个二年级孩子来说,可预期的规则比精细的激励更有效, 他自己就能心算出「再对 15 道题,就能看 15 分钟」。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

电视端 2.0 的真实运行截图(从群晖 Chat 里点开的 latest_screenshot.jpg,孩子姓名已打码)。 左上角是当日积分与答题数,顶部三项统计是今日任务 / 习惯打卡 / 专注时长; 右上角标签直接把规则写在屏幕上:语文 · 偏旁部首、对 +1 / 错 −1、语音开。 中间是正在答的题「“炒”的偏旁是什么?」和四个选项,下方「剩余积分:1」 与「今日已用 105/120 分钟」并列,最底下是 15 / 30 / 60 分钟三档兑换。 底部那行「【自律小达人】电视实时截图 22:54:07」是 MITV 推送时带的时间戳。

这张图不是示意图,是系统自己每 5 分钟抓一次、推到家长手机上的那一张。 它同时证明了三件事:APK 正在前台常驻、扣分规则已经生效、 语音开关是开着的。下面这几段讲的动画、语音和扣分,都能在同一段实拍录像里对上。

念出来,才不用大人陪

二年级孩子读题慢,家长不可能全程坐旁边。服务端 /api/tts 把题干、选项、 判分结果和讲解合成语音(走百度 TTS,按文本哈希缓存 mp3,最多留 40 条), 电视端用 Audio 播放:出题时念题,答完念「答对啦,加 1 分」或 「答错了,扣 1 分,正确答案是 B,……」。语音可在设置里关闭。

(现场语音实录:服务端把题干、选项、判分与讲解合成语音播放;下方为音轨自动转写文字。)

实拍录像里截出的 31 秒现场音轨(已剥离原视频的定位等元数据)。可以听到完整的 念题干 → 念选项 → 判分播报 → 念讲解 链路,以及孩子按遥控器的声音。

自动转写:
「题目 597−128 等于?选项 A 471,选项 B 470,选项 C 469。答对了,加 1 分。」
「题目 16+52 的结果与 66 相比,是?……答错了,扣一分,正确答案是 B 大于。 16+52=68,68 与 66 比较,大于。」
「题目,金鱼属于哪一类动物?……答对了,加 1 分。」
「题目,鸭子的英文是?选项 A dog,选项 B bird,选项 C duck,选项 D cat。」
四道题横跨数学、科学、英语,正好对应题库的学科分布;「答错了,扣一分」后面紧跟讲解, 就是 /api/tts 把解析一起念出来的样子。
(机器转写有个别字母误差,选项字母按画面校正。)

加减分也不是悄悄改数字。前端每次判分生成一个 .pts-float 元素: +1 向上飘 1.25 秒,-1 换一套红色样式。 分数变化必须被看见,奖励才有实感 —— 扣分同样要看得见。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动电视不是用来抢的,是用来挣的:答题积分与 HA 联动

两段都是从实拍录像里逐帧截出的循环动画(左上角姓名区域已统一打码)。 左:答对「金鱼属于哪一类动物」后,金色 +1 从画面中央浮起, 同时出现「✓ 答对啦!+1 积分」与讲解,正确选项 D 变绿; 右:答错「16 + 52 的结果与 66 相比,是?」后,粉红发光的 −1 以同一套飘字动画浮起,选错的 A 标红、正确答案 B「大于」标绿。 加分和扣分用的是同一个动画,只换配色和符号 —— 两种变化都必须被看见。

挣分不止答题一条路

答题是 2.0 新增的主渠道,但不是唯一渠道。手机端首页那条「完成学校作业 +10分」 就是任务渠道的实证 —— 分值由家长在后台设定,勾选完成即到账,取消会扣回。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

代码里真正改动 points 的只有前三条,最后一行只是状态。 这是刻意的 —— 渠道每多一条,规则的解释成本就多一分,孩子算不清账就不认账。 积分的去路同样不止一条:除了兑电视时间,还能在积分商城换零食、玩具、图书和游乐园, 这部分放在第六节。

答完要交账:实时快照 + 每日总结

答题不是答完就散。每条战报末尾都带一行实时累计: 今日:答对 116 / 答错 27 / 共 143 题,答题得 89 分; 跨过零点时,服务端再把前一天的正式日报推一次,内容是答对数、答错数、获得积分, 以及未掌握错题按学科的归集。家长随时发「答题报告 / 答题日报」也能立刻补一份。 日报只推一次,不会半夜刷屏。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

Chat 里能看到的大体记录:两条答题战报(含题目、作答、⭐ 获得 1 积分、讲解), 每条末尾都跟着「今日:答对 / 答错 / 共题数 / 答题得分」的累计行; 再往下是「【积分兑换】…兑换了 15 分钟电视时间(花费 15 积分,剩余 0 分)」的流水, 以及随消息上传的电视实时截图。一天里挣了多少、花在哪、屏幕当时在放什么,翻聊天记录就能对上。

界面设计遵循一条原则:孩子能自己完成一次完整闭环,不需要大人插手。 取题 → 选答案 → 看讲解 → 攒够分 → 兑换分钟 → 电视开始倒计时。全程遥控器上下左右加确认,六个按键。

五、防作弊:三道闸门,加一条扣分

一个奖励系统最大的敌人不是孩子不配合,而是作弊太容易。 如果选项顺序固定、答案能被前端看到、或者可以瞬间连点,那么这套系统第一天就会失效。 所以判分环节设了三道闸门。

闸门一:选项在服务端打乱,答案不下发

取题接口返回的是打乱后的选项,正确答案和解析都不在响应里。 服务端同时把「打乱后的序号 → 原始序号」的映射存进一个带时间戳的内存缓存:

// 取题:选项打乱,正确答案不下发 app.get('/api/quiz/next', (req, res) => { const q = pickQuizQuestion() const issuedAt = Date.now() pendingQuizzes.set(`${q.id}:${issuedAt}`, { correctShuffledIdx: q.correctShuffledIdx, order: q.order, // 打乱后的位置 → 原始下标 options: q.options, }) res.json({ id: q.id, q: q.q, options: q.options, issuedAt /* 无答案 */ }) })

缓存的 key 是 题目ID:签发时间戳,超过 10 分钟自动清理,条数超过 200 时触发一次回收。 这既防止内存堆积,也让「拿旧请求反复提交」失效。

闸门二:最短思考时间

QUIZ_MIN_THINK_MS = 4000。提交时间与签发时间相隔不足 4 秒, 直接判为「答太快」,不记分、不计入统计,只把正确答案和解析告诉他。这不是惩罚,是提示: 请认真看题。

但光有「太快不记分」,乱点依然零成本。所以 2.0 加了扣分: QUIZ_PENALTY_POINTS = 1,答错扣 1 分,总分不低于 0。 两条叠起来,闭眼连点的期望收益是负的 —— 孩子自己会算这笔账, 比任何「请勿盲选」的提示都有效。前端也会把 -1 用粉红发光的飘字明确显示出来, 和加分那枚金色 +1 是同一套动画。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

扣分那一帧的实拍(姓名区域已打码)。题目「16 + 52 的结果与 66 相比,是?」选了 A「等于」, 界面把选错的 A 标红、正确答案 B「大于」标绿, 下面直接写「✗ 答错了 扣 1 分,正确答案是 B 大于」并给出讲解 「16+52=68,68 与 66 比较,大于」;底部「剩余积分:15」比上一帧少了 1 分。 右上角「对 +1 / 错 −1」一直挂在那里,规则不需要家长再解释一遍。 判分瞬间还有一枚粉红发光的 −1 飘字从画面中央浮起, 循环动画见第四节右侧那张图 —— 扣分和加分用的是同一套动画,只是换了配色。

闸门三:缓存丢失不误判

服务重启、缓存过期都会让映射丢失。这时候如果按默认值兜底判分,就会把对的判成错的。 所以代码里明确处理了这种情况 —— 宁可让这一题作废,也不能冤枉:

app.post('/api/quiz/answer', (req, res) => { const { id, choice, issuedAt } = req.body const pending = pendingQuizzes.get(`${id}:${issuedAt}`) // 闸门一:思考时间过短,不记分 if (Date.now() - issuedAt < QUIZ_MIN_THINK_MS) return res.json({ tooFast: true, correctIdx, tip: q.tip }) // 闸门三:映射丢失,不作判分,请重新取题 if (!pending) return res.json({ stale: true, error: '题目已过期,请重新取题' }) // 用 order 把「打乱后的选择」还原成原始下标再比对 const correct = pending.order[choice] === q.k // ... })

顺带解决了一个隐性问题:错题本。quiz_wrong 表记录每道错题的累计错误次数和连对次数, 连续答对 2 次标记为 mastered = 1,自动从错题本里消失。 错题按错误次数倒序排列,所以孩子的复习顺序是数据决定的,不是家长凭印象安排的。

六、积分怎么「花」:电视时间只是商品之一

最容易想到的是换电视时间。电视端三档按钮走 1 积分 = 1 分钟(15 / 30 / 60 分), 简单到孩子能心算;但家长手机端还有一个积分商城,默认上架 12 件商品, 从 3 分的贴纸到 50 分的周末游乐园,把电视之外的奖励也纳进同一套积分:

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

商品由家长在后台自行增删改,可以改名字、改价格,还能勾上「这是电视奖励(自动开电视)」 并填时长 —— 所以电视时间只是商城里的一种商品类型,不是兑换的全部。 /api/shop/buy 统一处理:先校验积分够不够,再扣分、写 purchasedItems, 如果是电视奖励就叠加计时器,最后往 Chat 推一条「【积分兑换】」流水。 兑换记录因此天然可查,不用另做对账。

// 电视类商品:结束时间服务端算,客户端改不了 if (isTvReward && tvMinutes > 0) { state.tvTimerRemaining += tvMinutes * 60 state.tvTimerEndAt = Date.now() + tvMinutes * 60 * 1000 state.tvDailyUsed += tvMinutes } state.points -= cost // 所有商品统一扣分 pushChat('积分兑换', `${itemName}(花费${cost}积分,剩余${state.points}分)`)

还有一个容易被忽略的细节 —— 取消要退时间。 孩子看完 20 分钟主动关电视,剩下 40 分钟应该还给他,否则「节省」没有任何收益, 他只会选择把时间耗完。所以取消接口按分钟向上取整退回:

app.post('/api/tv/cancel', (req, res) => { const unused = Math.ceil((state.tvTimerRemaining || 0) / 60) if (unused > 0) state.tvDailyUsed = Math.max(0, state.tvDailyUsed - unused) res.json({ success: true, refunded: unused }) })

同时还有每日上限(tvDailyLimit / tvDailyUsed), 防止「一天狂刷一百道题然后连看三小时」这种情况 —— 规则要奖励努力,但不能奖励失控。

七、Home Assistant 只管设备:看见电视,按回 APK

先把职责边界说清楚,这一节很容易被误解:MITV 服务负责全部规则 —— 题库、判分、积分、计时、语音、截图推送、家长设置; HA 只负责设备侧三件事:回传电视当前画面、把电视强制切回 APK、转发遥控按键。 HA 里没有一行「几分换几分钟」的逻辑,它甚至不知道积分是什么。

到这里有一个漏洞:孩子可以挣到时间、也可以在电视上打开自律页面, 但页面开着不代表在看页面 —— 手指一划就切到动画片了,计时器还在走。

补上这个漏洞的是 Home Assistant。电视通过 xiaomi_miot 集成接入 HA, 成为一个媒体实体 media_player.xiaomi_rmv1_f96c_play_control, 而 HA 的媒体实体暴露了一个很有用的东西:entity_picture。 它指向一张实时的、当前正在播放的画面截图。

// 从 HA 拿到电视当前画面:先取 entity_picture,再用 token 换真图 const stateRes = await axios.get(`${haUrl}/api/states/${entityId}`, { headers }) const picUrl = stateRes.data?.attributes?.entity_picture || '' const picToken = picUrl.split('token=')[1]?.split('&')[0] if (!picToken) return null // 电视关机或不出图 const imgRes = await axios.get( `${haUrl}/api/media_player_proxy/${entityId}?token=${picToken}`, { responseType: 'arraybuffer' } )

拿到 Buffer 之后,两个动作:写成本地文件供网页读取,同时截取北京时间推送一条到群晖 Chat。 定时任务每 5 分钟跑一次,服务启动 5 秒后先推一条,方便验证链路是否通。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

这是 HA 抓回来、推到手机上的那张图。画面是真的在播动画片 —— 这就是「页面开着但偷偷看片」的现场证据。截图本身带字幕,无法伪造。

截图其实有两条来源。默认链路是上面这段 HA 的 entity_picture; 另外还留了 POST /api/tv/screenshot/upload,让 APK 主动把 base64 画面推上来。 两条都写进同一个 latest_screenshot.jpg,再由 MITV 服务每 5 分钟推一次到 Chat。 所以准确说法是:抓图、落盘、定时、推送都在 MITV,HA 只提供那张图; 而「强制跳回 APK」是 HA 的另一份工作,两者不是同一条链路。

这张图是整个系统里最有效的一环。它把「看不见的电视」变成了可观测对象, 争执从「你看了多久」变成「我们一起看记录」。技术上讲它只是两次 HTTP 请求, 但对家庭协作来说,它把监督成本降到了零。

反向控制:遥控器按键转给 HA

除了看见,还要能按住。网页上的遥控按钮不直接控制电视,而是转成 HA 服务调用。 ADB 的数字键码先映射成 miot 集成的按键名,通过 select.select_option 下发; 如果这条链不通,再回落到电视的 ADB 调试端口:

const adbToMiotKey = { 3: 'home', 4: 'back', 19: 'up', 20: 'down', 21: 'left', 22: 'right', 23: 'enter', 24: 'volumeup', 25: 'volumedown' } // 首选 miot 按键实体(走 HA) await callHA('select.select_option', 'select.xiaomi_rmv1_f96c_keycodes', { option: keyName }) // 兜底:直连电视 ADB 调试口 await axios.post(`http://${tvIp}:6095/controller?action=keyevent&keyCode=${keyCode}`)

两级回落的设计是有必要的:HA 重启、集成掉线、电视切换输入源,任何一种情况都会让第一级失效。 实际的容错体验是「按了没反应」和「按了稍微慢半拍」,差别很大。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

电视控制页实拍。顶部绿色状态条显示「已锁定」,下面是红色关机键、音量与静音、 方向键与 OK、信号源切换(TV、DTMB、AV、HDMI 1–3、USB), 最底部是三档兑换按钮 15 / 30 / 60 分钟。锁定状态、遥控、兑换都在同一屏, 家长不用在几个页面之间来回找。

1.0 就实现的 5 秒巡检:时间一到,强制回 APK

把电视「按住」的,是下面这段每 5 秒跑一次的巡检 —— 但它在 1.0 就已经实现,2.0 原样沿用,不是 2.0 的新功能。 计时没在跑、又不在自律小达人页面,就通过 HA 把应用切回 APK; 先读一次当前应用再动手,是为了避免每 5 秒重复下发导致画面闪烁,并加了 5 秒冷却:

// 每 5 秒:没在计时 且 不在 APK → 强制切回 setInterval(async () => { const state = getState() if (state.tvTimerActive) return // 有时间,允许自由切换 if (state.tvTimerEndAt !== 0) return const r = await axios.get(`${haUrl}/api/states/${appsEntity}`, { headers }) lastKnownApp = r.data?.state if (String(lastKnownApp).includes('com.mitv.dashboard')) return if (Date.now() - lastLockSwitch < 5000) return lastLockSwitch = Date.now() await haWs.callService('select', 'select_option', appsEntity, { option: '自律小达人 - com.mitv.dashboard' }) }, 5000)

计时一开始,巡检立刻放行 —— 孩子可以用这段时间自由看片;时间归零,锁重新生效。 这就是「APK 强制打开」的完整含义:不是开机自启一次,而是持续保持在场。 这套「按住电视」的机制从 1.0 起就没变过,2.0 改的只是孩子用什么方式挣到这段时间。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动电视不是用来抢的,是用来挣的:答题积分与 HA 联动

实拍录像的后半段(两帧均已打码):画面从答题页离开,进到电视自带的影视首页, 点开《哈小浪》详情页再播到正片。这两帧拍的是「离开 APK」这一步; 而「离开之后会被拉回来」由上面那段 5 秒巡检负责,不在录像里 —— 两者合起来才是完整的闭环。也正因如此,MITV 每 5 分钟推到 Chat 的实时截图才有意义: 家长看到的是电视当时真正在放什么,而不是一个「答题页开着」的假象。

八、群晖 Chat:家长的一句话遥控器

再好的界面,家长也不愿意每次都打开电脑。所以最高频的那个入口被做成了一个聊天窗口。

出口是群晖 Chat 的 Incoming Webhook:答题战报(含题目、作答、正确答案、讲解、今日统计)、 跨天的答题日报、积分兑换流水、任务完成通知、每 5 分钟一张电视截图,都推到同一个会话里。 账目大体都摊在这一段聊天记录里,翻一翻就能对上,不用另开系统查。 答题战报长这样:

✅ 答对了 数学·100以内加减 题目:小明有 37 颗糖,妈妈又给了他 25 颗,一共有多少颗? 作答:62 颗 ⭐ 获得 1 积分 讲解:37+25,先算 30+20=50,再算 7+5=12,合计 62。 —— 今日:答对 18 / 答错 2 / 共 20 题,答题得 16 分

入口更简单:一个 /api/chat/incoming 接口接收 Chat 的回调,用几条正则解析自然语言。 家长在手机上打字,就能完成大部分操作:

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

Chat 不只是加分工具,它同时是一台电视遥控器:发「关闭电视 / 取消计时」 立刻停表并把剩余分钟退回,发「+15分钟」直接叠加时间、电视开始倒计时, 发「截图」马上回一张当前画面,发「答题报告」推回今日小结; 任意文字则作为通知直接推送到电视屏幕上。

除聊天窗口之外,2.0 还有一套独立的家长手机端: /app/dist 下的单页应用,与电视端 tv.html 是两份前端, 共用同一套 API 和同一个 SQLite 账本。底部 8 个 Tab —— 首页、任务、答题、计时、电视、习惯、商城、设置 —— 说明后台管的不只是电视:

  • 任务 / 答题 / 计时 / 习惯:增删任务与分值、导入题库、番茄钟专注、每周习惯打卡;

  • 电视:开关、时长、HA 连接配置、遥控按键;

  • 商城:上架 / 改价 / 兑换礼品,兑完的流水同步回 Chat;

  • 设置:家长 PIN、每日电视限额(15~240 分钟滑块)、孩子显示名。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

手机端首页:顶部三项圆环是任务完成 0/1、习惯打卡 0/6、专注时间 0h, 中间三个快捷入口是任务、专注番茄钟、电视(已开启), 下方「今日任务」写着「完成学校作业 +10分」—— 这是任务渠道也能挣分的实证。 底部 8 个 Tab 里,商城就是兑换礼品的入口。

电视端的解锁和设置要输 PIN,孩子用遥控器按不出来;手机端改完的状态, 电视 5 秒内刷新到。对孩子是前台,对家长是后台,两边是同一本账。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

群晖 Chat 里的「帮助」回执,把可用指令一次列全:加分、加时间、创建 / 完成 / 取消任务、 关闭电视、任意文字推送到电视。上方还能看到「【时间到】看电视时间到啦!已自动锁定」 的自动通知,以及「当前积分 0 分」的查询回执,最上面那条是每 5 分钟推一次的电视实时截图。 高频动作都在这个聊天窗口里;需要改配置、上架商品时才打开手机端页面。

这套「自然语言指令」看起来有点土,但把使用成本降到了最低 —— 不需要打开面板、不需要记菜单,想起来就发一句。 能长期跑下去的家庭系统,往往不是功能最全的那个,而是阻力最小的那个。

九、最坑的一处:兜底路由吃掉了 API

上线之后遇到一个非常典型的问题:电视上除了主页面,其他接口偶尔全部报错, 但用浏览器直接访问同样的地址又是好的。

根因在 Express 的路由注册顺序。这是一个 SPA(单页应用)项目,为了让前端路由不 404, 代码里有一条兜底规则把所有未匹配的 GET 请求都返回 index.html:

app.get('*', (req, res) => { if (req.path.startsWith('/api/')) return res.json({ error: 'not found' }) res.sendFile(indexHtml) })

问题是这条兜底规则被写在了截图接口之前。 Express 的匹配是「按注册顺序,先命中先返回」,于是 GET /api/ha/tv/screenshot 还没走到自己的处理器,就被 app.get('*') 接走了 —— 返回的不是图片,是一段 JSON 错误。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

同一个 URL 返回 JSON 而不是图片,问题不在网络,在注册顺序。 修复方法很朴素:把截图路由整块移到兜底路由之前。

修复本身不复杂,但那块路由有二十多行、内部还有嵌套的括号,手工剪切粘贴很容易切错位置。 最后写了一个小脚本,用括号配平定位代码块的起止行,再整体搬到 app.get('*') 之前:

# 用括号配平找到截图路由块的边界,整块搬到兜底路由之前 for i, l in enumerate(lines): if "app.get('/api/ha/tv/screenshot'" in l: start = i; break depth = 0 for j in range(start, len(lines)): depth += lines[j].count('(') - lines[j].count(')') depth += lines[j].count('{') - lines[j].count('}') if depth <= 0 and j > start: end = j; break # 把 block 插到 app.get('*') 之前,并跳过原位置

顺便把那条已经失效的重复定义也删掉了 —— 代码里留一段永远不执行的死路由, 下一个维护的人(可能是三个月后的自己)一定会被它误导一次。

十、跑起来之后

实际使用的画面是这样的:电视上是「每日答题」看板、答题进度、剩余积分, 以及一个正在倒计时的「看电视时间 18:43」。倒计时是服务端算的, 关掉网页、重启电视,回来接着走。

电视不是用来抢的,是用来挣的:答题积分与 HA 联动

电视实拍(另一时刻的画面)。顶部是答题进度与今日得分,中间是剩余看电视时间倒计时; 下方是积分余额与兑换档位。界面浮在电视壁纸之上,深色页面在客厅光线下不刺眼。

题库扩到近 1.2 万道之后,一天刷掉一百多题也不会撞题。11 月 14 日 Chat 里的收尾是 「今日:答对 116 / 答错 27 / 共 143 题,答题得 89 分」—— 实时累计行把一天的努力摊开, 跨天那张日报再按学科列出未掌握错题,比一句笼统的「今天正确率 80%」有用得多。 紧接着一条「【积分兑换】…兑换了 15 分钟电视时间(花费 15 积分,剩余 0 分)」, 从挣到花的闭环走完,Chat 里留着完整流水。

跑了一段时间之后,有几个观察值得记下来:

  1. 规则透明比激励强度更重要。「1 题 1 分、1 分 1 分钟」这种粗糙的比例, 比「难题 3 分、连对加成」更容易被执行。孩子能自己算账,就不会觉得被临时加码。

  2. 可溯源是信任的基础。那张每 5 分钟推一次的截图,被要求的次数其实很少 —— 但只要它存在,双方的行为都会收敛。监督的价值往往在于「可能被看见」,而不是「真的被看见」。

  3. 降低操作阻力,系统才活得久。高频动作压在聊天窗口里,一句话就能加分、加时间、关电视; 只有改配置、上架商品这类低频动作才需要打开手机端页面。把入口按频率分层,比堆功能管用。

  4. 最该被自动化的,是「大人记得做某事」。1.0 已经把电视按住了, 唯独靠家长每次手动发一句加分,漏发一次规则就失信一次; 2.0 没有重做控制链路,只把挣分交给服务端判分,家长只在想额外奖励时才出现。 依赖自觉的环节,迟早会断在某一个忙碌的晚上。

  5. 服务端做判断,前端只做展示。这不是安全洁癖,是为了让规则本身可靠 —— 当孩子知道分数改不了,他就不再去试。

整套系统跑在一台群晖的容器里:一个 Express 服务、一个 SQLite 文件、两套前端(电视 APK 与手机 SPA)、 一个 Home Assistant 集成、一个聊天 webhook。没有云服务,没有订阅, 断电重启之后自己会爬起来。

家庭实验室 · 2026 年 11 月

作者声明本文无利益相关,欢迎值友理性交流,和谐讨论~

展开 收起
1评论

  • 精彩
  • 最新
  • 东芝电视装不了,悬浮权限拿不到。

    校验提示文案

    提交
提示信息

取消
确认
评论举报

相关文章推荐

更多精彩文章
更多精彩文章
最新文章 热门文章
0
扫一下,分享更方便,购买更轻松