分类目录归档:代码

小米门铃视频“永久保存”那点事

最近入手了一个小米智能门铃 3S。这个门铃很有意思!它不支持本地存储,所有视频只存在小米云存储里面。但有个问题是,如果不订阅它的云存储服务的话,小米云只会保留最近 72 小时的视频,过期就直接删掉了。

门铃为了省电,大多数时间都是处于睡眠状态,只有在三种情况下才会被唤醒录像:

  1. 检测到门口有人
  2. 有人按了门铃
  3. 有人试图强行拆除门铃

这么看来,视频只放云上,也算是在省电和用户随时可以查看监控视频之间做出的一个相对完美的妥协。如果视频放在本地,门铃在睡眠状态下的时候,用户也无法查看监控视频了。显然,门铃因为会睡眠,也就不提供 RTSP / ONVIF 进行 24 小时不间断录像。刚巧了,群晖 Surveillance Station 随 NAS 只送了两个摄像头授权,早就被我的两个摄像头占满了。

下单之前,我想当然地以为 Home Assistant 这么庞大的社区,肯定早就有现成的 Integration 可以把门铃视频存下来了。结果到手一查,我一直在用的用来管理米家设备的 Xiaomi Miot Auto 并没有这个功能 😅。

求人不如求己,只能自己折腾了,我的基本思路是用 Home Assistant + Xiaomi Miot Auto 把云端视频自动下载、解密,再存到群晖 NAS 上。

简单研究了下,发现门铃每次被唤醒,都会录一段最长 30 秒的视频。每段监控视频都会带一个事件类型,比如有人经过是 Pass,有人在门前停留是 Pass:Stay,有人按门铃则是 Pass:Bell。

简单记录一下整个过程,以及中间踩的几个坑。

不想重复造轮子

之所以要基于 Home Assistant + Xiaomi Miot Auto 来实现,是因为它们本身就已经帮我搞定了小米账号的登录和 token 刷新,门铃的摄像头实体里面也能看到最新事件的时间(motion_video_time)、事件类型(motion_video_type)以及一个 m3u8 播放地址(stream_address)。

既然轮子都已经有了,我就不想重复造了,直接基于这个 Integration 来实现明显是最高效和简单的方案。看上去只要拿到 stream_address,用 ffmpeg 一转就完事了,对吧?

图样图森破。

四个坑

坑一:m3u8 是加密的

请求 stream_address 拿到的是一个 HLS 播放列表,也就是 m3u8 文件,本质上就是一份清单,指向好几段分片视频;这里面的每一段都用 AES-128 加了密:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-KEY:METHOD=AES-128,URI="https://sg.app.processor.smartcamera.api.io.mi.com/common/app/play/v1/key?did=<DEVICE_ID>&keyInfo=...",IV=0x<IV>
#EXTINF:3.931311
https://sg.app.processor.smartcamera.api.io.mi.com/common/app/play/v1/video?did=<DEVICE_ID>&stoId=...
#EXT-X-DISCONTINUITY
...
#EXT-X-ENDLISTCode language: PHP (php)

原理上倒不复杂:从 #EXT-X-KEY 的 URI 拿到 16 字节的密钥,再配合 IV,对每个分片做 AES-128-CBC 解密就好了。但要把这些细节一个一个测,本质上是一个非常无聊的反复试错的过程。

坦白说,这部分我是交给 AI 去做的。我用的是 Google Antigravity,把无聊的试错扔给它,我只负责拿结果 😂。

坑二:只有 4 秒的视频

解密成功之后,我很自然地把这些分片当成标准的 MPEG-TS(.ts)分片,也就是一段长视频里切出来的碎片,把解密后的字节依次追加到同一个文件 /tmp/test_doorbell.ts 里,再用 ffmpeg 转封装成 MP4:

ffmpeg -y -i /tmp/test_doorbell.ts -c copy /tmp/test_doorbell.mp4Code language: Bash (bash)

同一段视频在米家 App 里有 14 秒多,结果转出来只剩下 4 秒了,外加满屏的 Non-monotonous DTS 报错 🤦‍♂️:

[mp4 @ 0x5643e5ef0a00] Non-monotonous DTS in output stream 0:1; previous: 193133, current: 54751; changing to 193134. This may result in incorrect timestamps in the output file.
[mp4 @ 0x5643e5ef0a00] Non-monotonous DTS in output stream 0:1; previous: 193134, current: 55782; changing to 193135. This may result in incorrect timestamps in the output file.
...
frame=  213 fps=0.0 q=-1.0 Lsize=    2649kB time=00:00:04.02 bitrate=5393.4kbits/s speed= 104x

把每个解密后分片的前 8 个字节打印出来看了下,全部都是 0000002066747970:

Seg 0: enc=826720 dec=826717 magic=0000002066747970
Seg 1: enc=569072 dec=569064 magic=0000002066747970
Seg 2: enc=557232 dec=557222 magic=0000002066747970
Seg 3: enc=497008 dec=497007 magic=0000002066747970
Seg 4: enc=271360 dec=271354 magic=0000002066747970

简而言之,这个分片文件本来就是 MP4,不是 TS。为什么呢?MP4 文件是由一个个 box 组成的,每个 box 开头先是 4 个字节的大端序长度,后面紧跟 4 个字节的 ASCII 类型名:

  • 00 00 00 20:box 的长度,0x20 也就是 32 字节。
  • 66 74 79 70:box 的类型,按 ASCII 来看,0x66 是 f,0x74 是 t,0x79 是 y,0x70 是 p,连起来就是 ftyp。

ftyp(File Type Box)通常就是一个 MP4 文件开头的第一个 box。如果是 TS 分片的话,开头应该是同步字节 0x47 才对。

也就是说,小米的每个三秒左右的分片都是一个完整、独立的 MP4 文件,而不是常见的 TS 分片或者 fMP4 分片。每个分片的时间戳都从头开始,直接按字节拼接当然就乱套了。

解法是把每个分片单独存成临时文件,再用 ffmpeg 的 concat demuxer 无损拼接。这里的 -c copy 就是直接拷贝画面,不再重新编码:

ffmpeg -f concat -safe 0 -i concat_list.txt -c copy -movflags +faststart output.mp4Code language: Bash (bash)

这样拼出来的就是完整的 2K HEVC(也就是 H.265)视频,不重新编码,速度也飞快。

坑三:60 秒轮询会漏视频

Xiaomi Miot Auto 每 60 秒轮询一次,而且只取最新的一个事件。如果有人在门口停留,短时间内触发了两段视频,Home Assistant 下次轮询的时候只能看到后面那一段,前面那段就永远错过了。

所以脚本除了接收 Home Assistant 传过来的地址,还会直接用 Xiaomi Miot Auto 保存在 .storage/xiaomi_miot/auth-*.json 里的登录凭证,去调用小米云的事件列表接口(common/app/get/eventlist),把最近的事件都检查一遍,和 NAS 上已经下载的视频做对比,把还没下载的都补下载下来。

顺带的好处是,token 的刷新完全依赖 Home Assistant + Xiaomi Miot Auto,代码中完全不需要任何账户信息。

坑四:视频还没传完

门铃每个事件最多录 30 秒,边录边往云端上传。如果事件一出现就马上去下载,很可能只拿到前一两个分片。

解决方法也很简单粗暴:

  1. 在自动化里面,检测到 motion_video_time 变化后,先等 35 秒再调用脚本。
  2. 脚本里检查 m3u8 里面有没有 #EXT-X-ENDLIST。没有的话说明还在上传,等 10 秒再拉一次。

整体架构

整个流程大致如下:

//www.plantuml.com/plantuml/png/RP9VQzDG6CRlpwTutNrGD_yCBsGn4qH85NBZnQ24DTi2JLAQL6yxtDgcBIkjS_r3fTiaD8XRfsAPjRLV9kVav6fVmPCpuonu-_wUz-LvddFMR4VDEmKZYsuvEvgXWVh65goyQcvh2EKOqZVrd6ewi1Ir_UZTsoLGRLXV9FYY4Nxv5aoQ74gBy9MkMeOEicuv81OSYslaHSqp4MZv4wxwk7x4kRZ8WyiQdSteNe_qFtEkBFAKgF1n2g5r41w318yWkAm6qsDSFSQZ4gwKGhUyOLvDAy7yG_Y-2m-NWRacxV7Lr4LPoye16JNWmHq8Xoq482TNwEoSZav9lqYEBe99BVWvPbG2WM6POSDoTCjClzOpcfLsT5RVRR2TlAOQQJMJoMksZJGpW_WvyRzwtEYmq4XC5E6PYt_jmyfzxeIF5JQsTxHiDhrf6ORAxYc9FJBmcBrhOxZIfYS-kZONxp3NTDxyuvfdP3t5lT1vXtmz8NqtxEsnsEJyEnsMejCcYwRSr1TN3iPAOHLmgyvAZ5hUt-pn-N-hJJRZkVXK4UukhmhI4mdey2FJR9ZunpWyz65hoyXfsq1gKtBW9Jkfs6hT9MsFz0ViUV1P1-INztXnIb8BBdNnlXTMnwIu2y-L5wcRnd1v4XrssLEI0pygxaR5NZZmq1fRZt_rRm
门铃视频归档的时序图
  1. 门铃把加密的分片上传到小米云(保留 72 小时)。
  2. Xiaomi Miot Auto 每 60 秒轮询一次,更新门铃实体的属性。
  3. Home Assistant 自动化在 motion_video_time 变化后等 35 秒,通过 shell_command 调用下载脚本;另外在 Home Assistant 启动时和每 30 分钟也会各跑一次,用来兜底。
  4. 脚本读取最新的认证文件,查询事件列表,下载 m3u8 和密钥,解密分片,用 ffmpeg 拼接。
  5. 视频按日期保存到 /volume1/surveillance/Xiaomi Doorbell 3S/YYYY-MM-DD/,文件名类似 2026-10-30_21-20-29_Pass-Stay.mp4。之所以选这个目录,是因为它也是 Surveillance Station 存放监控录像的地方,这样查看视频的时候都在一个地方,很方便。
  6. 脚本把运行状态写进一个 JSON 文件,Home Assistant 用一个 Sensor 去读取它,一旦出错就推送通知。

下载脚本

下载脚本是用 Python 写的,直接跑在群晖 Home Assistant 套件自带的 Python 3.13 上。脚本用到的 requests、cryptography,以及 Xiaomi Miot Auto 用到的 micloud 都已经内置了,不需要额外 pip install 任何东西。

脚本里最核心的就是解密这几行:先用 AES-128-CBC 解开每个分片,再去掉 PKCS7 填充:

def decrypt_aes_cbc(data: bytes, key: bytes, iv: bytes) -> bytes:
    """Decrypt AES-128-CBC segment and remove PKCS7 padding."""
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
    decryptor = cipher.decryptor()
    raw = decryptor.update(data) + decryptor.finalize()
    try:
        unpadder = padding.PKCS7(128).unpadder()
        return unpadder.update(raw) + unpadder.finalize()
    except ValueError:
        return rawCode language: Python (python)

configuration.yaml

在 configuration.yaml 里面加上允许访问的目录、两个 shell_command(一个给事件触发用,一个给定时同步用),以及一个读取状态 JSON 的 command_line 传感器:

homeassistant:
  allowlist_external_dirs:
    - "/volume1/surveillance/Xiaomi Doorbell 3S"

shell_command:
  download_doorbell_video: >-
    /var/packages/HomeAssistant/target/bin/python3.13
    /var/packages/HomeAssistant/home/.homeassistant/download_doorbell_video.py
    --url "{{ stream_address }}"
    --time "{{ video_time }}"
    --type "{{ video_type }}"
    --limit 8
  sync_doorbell_cloud: >-
    /var/packages/HomeAssistant/target/bin/python3.13
    /var/packages/HomeAssistant/home/.homeassistant/download_doorbell_video.py
    --limit 15Code language: YAML (yaml)

限于篇幅,这里没有贴完整代码,只简单说明一下用到的几个参数:

  • --url、--time、--type:最新事件的播放地址、时间和事件类型。
  • --limit:从小米云事件列表里取最近多少个事件来检查。NAS 上已经有的会跳过,只下载还没下载的。事件触发时用 8,定时同步时用 15,都不算大,是为了让脚本在 shell_command 的 60 秒超时之内跑完。
  • --backfill:补齐模式。脚本会按照 --limit 的数值分页,一页一页往前翻,把小米云上还留着的视频都检查一遍。这个模式比较耗时,建议手动跑,不要放进自动化里。

automations.yaml

自动化有三个触发器:事件更新、Home Assistant 启动、每 30 分钟一次。事件触发的分支里先等 35 秒:

triggers:
  - id: motion_event
    trigger: state
    entity_id: camera.madv_mi3sg_xxxx_video_doorbell
    attribute: motion_video_time
  - id: ha_startup
    trigger: homeassistant
    event: start
  - id: periodic_catchup
    trigger: time_pattern
    minutes: "/30"Code language: YAML (yaml)

另外再加一个自动化:状态 Sensor 变成 ERROR 的时候,Home Assistant 会推送一条通知。这里就不再赘述了。

补齐云端已有的视频

配置完成后,手动带上 --backfill 跑一次脚本,就能把小米云上 72 小时内的视频全部拉下来了。日志大概是这样的:

[INFO] Downloading cloud event: time=2026-10-30 21:20:29 type=Pass:Stay fileId=...
[INFO] Saved /volume1/surveillance/Xiaomi Doorbell 3S/2026-10-30/2026-10-30_21-20-29_Pass-Stay.mp4 (2655.5 KB, 5 segments)

踩坑小结

  • 别想当然地以为 m3u8 里都是 TS 分片。 先看看解密后分片的前 8 个字节,ftyp 一出来,问题就一目了然了。
  • Home Assistant 的 shell_command 有 60 秒超时。 自动运行时,--limit(一次检查的事件数)要设小一点;大批量补下载的话,建议手动跑 --backfill。
  • 小米云偶尔会卡住。 给每个 HTTP 请求加上重试,能省掉不少莫名其妙的失败。
  • 群晖上的文件权限。 手动通过 SSH 测试时用的是自己的管理员账号,而 Home Assistant 是以 HomeAssistant 用户运行的,所以将代码交接给 HomeAssistant 运行的时候,如果遇到问题,要多留意下日志、状态文件和目录的权限。

现在门铃每次触发,视频都会自动躺进 NAS 里,再也不用担心 72 小时后就没了。本身家门进出人也不多,而且 30 秒视频也很小,所以我并没有在脚本里面设置什么最长保留时间。

今天就先写到这了。

reMarkable 2:一个可玩性很强的新玩具

趁着生日,又赶上黑五,终于顺利批下预算入了一个新玩具: reMarkable 2。😂

reMarkable 2
reMarkable 2

在入之前,就好几个哥们向我推荐过这个设备了。最终说服我自己购买的,不外乎是,它极致的纸质书写手感、轻薄的设计等等卖点。但当我用了 reMarkable 2 一段时间之后,发现它的实用性和可玩性远超我的想象。最让我出乎意料的一点是,reMarkable 的系统是基于 Linux 的,而且制造商还慷慨地开放了 SSH 和提供了 root 密码。

刚好赶上年底休假,我也把这玩意好好把玩了一下,顺便水一篇文章来当作笔记了。

功能完备性

语言问题

reMarkable 2 对于非拉丁文字的支持非常的糟糕,中日韩文字都会显示为方块,如下图。

方块
方块

有个折中的解法是尽量阅读 PDF,但是我们也不能保证我们所阅读的 PDF 文档一定内嵌了所有必要字体。如果一些中日韩 CJK (Chinese, Japanese, and Korean) 字符依赖未内嵌的字体,在 reMarkable 上面还是会显示成方块。

好在 reMarkable 操作系统是基于 Linux,且开放 SSH 并提供了 root 密码,我们可以很方便的安装中日韩 CJK (Chinese, Japanese, and Korean) 字体。可以参考这个页面提供的 RMPKG 来安装。

cd /tmp;
wget https://files.davisr.me/projects/rcu/download-fontrmpkg/NotoSansCSFont.rmpkg
wget https://files.davisr.me/projects/rcu/download-fontrmpkg/NotoSansCTFont.rmpkg
wget https://files.davisr.me/projects/rcu/download-fontrmpkg/NotoSansJPFont.rmpkg
wget https://files.davisr.me/projects/rcu/download-fontrmpkg/NotoSansKRFont.rmpkg
chmod +x *.rmpkg
./NotoSansCSFont.rmpkg --install
./NotoSansCTFont.rmpkg --install
./NotoSansJPFont.rmpkg --install
./NotoSansKRFont.rmpkg --install
rm -f *.rmpkgCode language: Bash (bash)

这样子,reMarkable 内嵌的 Noto Sans 字体就会支持 CJK 字符的显示。

类型多样化

reMarkable 2 原生支持的文档格式只有 PDF 和 EPUB,这显然是远远不够的。Toltec 这个社区维护的针对 reMarkable 的免费软件源可以帮助到我。通过 Toltec 我们可以在 reMarkable 2 KOReader。依托于 KOReader 强大的格式兼容性,我们就可以用 reMarkable 阅读几乎所有主流格式的文档和书籍了。

## 安装完 Toltec 之后,通过 opkg 命令来安装 remux 和 KOReader
opkg update
opkg upgrade
# remux
# 支持多任务的启动器
opkg install remux
# KOReader
# 支持 PDF、DjVu、EPUB、FB2 等多种格式的电子书阅读器
opkg install koreaderCode language: Bash (bash)

值得一提的是,reMarkable 的原生书架和 KOReader 的书架不能共享,当然我们可以用一些云存储来解决,后面会详细介绍。另外一定要再三确认,Toltec 是否支持你所持有 reMarkable 的系统版本。

Toltec 里面还有很多有意思的应用,有兴趣的可以慢慢研究。

文章及文档管理

reMarkable 提供了一个浏览器插件。这个插件可以让用户点击一下就能将网页转换成 EPUB 文章并放到 reMarkable 的书架上。虽然方便,但是我用起来不怎么习惯:

  1. 插件转换成的 EPUB 内容的格式非常奇怪。
  2. Pocket 是我一直在使用的稍后阅读应用,我并不打算切到 reMarkable。另外我也不希望把所有收藏的页面都放在 reMarkable 书架上面。

于是我着手尝试将 Pocket 收藏的页面自动推送到 reMarkable ,又于是我想起了 n8n 这个开源的工作流自动化平台。

之前对 n8n 只是略有耳闻,这次尝试了一下,的确很方便。我将 n8n 部署在 NAS 上面,然后轻松完成了工作流的搭建,下图 n8n 对 Workflow 的展示和编辑 UI,非常的直观和友好。

将 Pocket 收藏上传至 Google Drive
将 Pocket 收藏上传至 Google Drive

我通过调度器定时运行如上图工作流:

  1. 读取上一次检测的时间点,从 Pocket 读取该时间之后我所收藏的网址列表。Pocket 的获取 API 有一个 since 参数,通过这个参数我们可以排除掉所有这个时间点之前的网址,以避免重复拉取。
  2. 如果列表不为空,会使用 OneSimpleAPI 将这些页面依次转换成 PDF 文档并上传至 Google Drive。
  3. 把检测时间点更新为当前。

以上的 OneSimpleAPI 和 Google Drive 的工作流节点都是 n8n 原生支持的,调用起来非常方便。

在创建上面这个工作流的时候,有几点是需要注意:

  1. 我发现 Pocket 的 since 参数是以最后更新时间来作为参照物的,所以你如果希望把一些旧的页面推送至 Google Drive,可以将其做一些修改,例如:修改一下标签、重新收藏一下这个页面。下次被调度的时候,这个页面就会被推送至 Google Drive。
  2. 当你在 Pocket 中删除了一个收藏,Pocket 在彻底删除这个收藏之前,会先将这个收藏的状态改成为 2。所以在转换时需要过滤掉状态为 2 的收藏,以免将已删除的收藏被上传了。
  3. n8n 的 Google Drive 库支持两个认证方式,分别是 OAuth 2 和服务账号。OAuth 2 要求你的回调地址是使用域名的。出于安全考虑,我的 NAS 并没有开放外部直接访问,是需要 VPN 的,因此也没有绑定顶级域名。如果各位和我情况类似的话,请直接考虑使用服务账号。务必将用来上传文件的 Google Drive 目录已读写权限共享给服务账号,毕竟服务账号大概率并不是你的 Gmail。
授权给服务账号的权限
授权给服务账号的权限

其实我完全可以直接将文章直接放入 reMarkable 的书架,但我个人觉得这并不是一个好主意。我还是希望通过限制 reMarkable 书架上的数量,来保证我能够完成当面的阅读后,再去 Google Drive 选择新的读物。我不想让过多的文章来毁掉我的 reMarkable 书架。

我之前也提到 KOReader 和 reMarkable 2 不能共享书架,所以将文档和书籍放在 Google Drive 的中间云存储是一个既能保证书架有条理,也能保证两方都能方便拉取文档的最适合折中方案。

出版物管理

长久以来,我一直是使用 Calibre 来管理各种出版物,例如:小说、书籍、漫画和论文等等。后来有了 NAS,我又将 Calibre 的书库迁移到了 NAS 中,并在 Docker 容器中运行 Calibre Web,这样子我在任何设备都能对我的出版物收藏进行管理。

Calibre Web 其实并非是 Calibre 的官方出品,在 GitHub 也有不止一个 fork。我个人比较推荐 linuxserver.io 的镜像。假如你在部署 Calibre Web 之前没有使用过 Calibre,那你会需要一个初始的书库文件 metadata.db 。你可以安装 Calibre 来初始化一个,或者直接从这里下载。

Calibre Web 除了给用户提供了对 Calibre 的书库基于 Web 的管理能力,还支持 OPDS 目录。

The Open Publication Distribution System (OPDS) Catalog format is a syndication format for electronic
publications based on Atom and HTTP. OPDS Catalogs enable the aggregation, distribution, discovery,
and acquisition of electronic publications.

OPDS Catalogs use existing or emergent open standards and conventions, with a priority on simplicity.

OPDS Catalog 1.2

这样子,我们就可以通过如下方法来搜索出版物:

# curl --user {username}:{password} "http://{IP}:{port}/opds/search/{queryString}"
curl --user username:password "http://{IP of NAS}:8083/opds/search/root%20cause"Code language: Bash (bash)

如能搜索到,ODPS 将会把出版物信息以 XML 格式返回,其中包括书名和下载链接:

<?xml version="1.0" encoding="UTF-8"?>
<feed
  xmlns="http://www.w3.org/2005/Atom"
  xmlns:dc="http://purl.org/dc/terms/"
  xmlns:dcterms="http://purl.org/dc/terms/">
  <id>urn:uuid:2853dacf-ed79-42f5-8e8a-a7bb3d1ae6a2</id>
  <updated>2022-12-29T16:23:55+00:00</updated>
  <link rel="self"
        href="/opds/search/root cause?"
        type="application/atom+xml;profile=opds-catalog;type=feed;kind=navigation"/>
  <link rel="start"
        href="/opds"
        type="application/atom+xml;profile=opds-catalog;type=feed;kind=navigation"/>
  <link rel="up"
        href="/opds"
        type="application/atom+xml;profile=opds-catalog;type=feed;kind=navigation"/>
  <link rel="search"
      href="/opds/osd"
      type="application/opensearchdescription+xml"/>
  <link type="application/atom+xml" rel="search" title="Search" href="/opds/search/{searchTerms}" />
  <title>Calibre-Web</title>
  <author>
    <name>Calibre-Web</name>
    <uri>https://github.com/janeczku/calibre-web</uri>
  </author>
  <entry>
    <title>Root Cause Analysis Handbook</title>
    <id>urn:uuid:06a66a9a-59d4-415b-a472-a413b8cf923d</id>
    <updated>2022-12-22T07:56:20+00:00</updated>
    <author>
      <name>ABS Consulting</name>
    </author>
    <publisher>
      <name>Rothstein Publishing</name>
    </publisher>
    <dcterms:language>eng</dcterms:language>
    <category scheme="http://www.bisg.org/standards/bisac_subject/index.html"
              term="Business &amp; Economics"
              label="Business &amp; Economics"/>
    <summary>Are you trying to improve performance, but find that the same problems keep getting in the way? Safety, health, environmental quality, reliability, production, and security are at stake. You need the long-term planning that will keep the same issues from recurring. Root Cause Analysis Handbook: A Guide to Effective Incident Investigation is a powerful tool that gives you a detailed step-by-step process for learning from experience. Reach for this handbook any time you need field-tested advice for investigating, categorizing, reporting and trending, and ultimately eliminating the root causes of incidents. It includes step-by-step instructions, checklists, and forms for performing an analysis and enables users to effectively incorporate the methodology and apply it to a variety of situations. Using the structured techniques in the Root Cause Analysis Handbook, you will: Understand why root causes are important. Identify and define inherent problems. Collect data for problem-solving. Analyze data for root causes. Generate practical recommendations. The third edition of this global classic is the most comprehensive, all-in-one package of book, downloadable resources, color-coded RCA map, and licensed access to online resources currently available for Root Cause Analysis (RCA). Called by users &#34;the best resource on the subject&#34; and &#34;in a league of its own.&#34; Based on globally successful, proprietary methodology developed by ABS Consulting, an international firm with 50 years&#39; experience in 35 countries. Root Cause Analysis Handbook is widely used in corporate training programs and college courses all over the world. If you are responsible for quality, reliability, safety, and/or risk management, you&#39;ll want this comprehensive and practical resource at your fingertips. The book has also been selected by the American Society for Quality (ASQ) and the Risk and Insurance Society (RIMS) as a &#34;must have&#34; for their members.</summary>
    <link type="image/jpeg" href="/opds/cover/30" rel="http://opds-spec.org/image"/>
    <link type="image/jpeg" href="/opds/cover/30" rel="http://opds-spec.org/image/thumbnail"/>
    <link rel="http://opds-spec.org/acquisition" href="/opds/download/30/pdf/"
          length="24812876" mtime="2022-12-22T07:56:20+00:00" type="application/pdf"/>
  </entry>
</feed>Code language: HTML, XML (xml)

有了对 OPDS 目录的支持,我创建了如下工作流来实现对出版物的搜索并将所有搜索文档都上传至 Google Drive。

将书籍从 Calibre 书库搜索出来并上传至 Google Drive
将书籍从 Calibre 书库搜索出来并上传至 Google Drive

我通过 Webhook 将搜索字符串传递给 OPDS 来搜索出版物。

其他

reMarkable 2 还有很多值得探索的地方,例如模板方面。reMarkable 2 对 PDF 格式支持的完成度惊到了我,尤其是对 PDF 内链的支持。很多电纸书,对 PDF 文档内的链接几乎是不支持的。即便是像目录这种非常必要的内链,也是通过调出目录列表来实现点击跳转,并不能直接在阅读过程中点击跳转。

我们可以通过 reCalendar 生成模板,然后用 reMarkable 2 以日月年维度来做计划和总结了。为了防止页面加载过慢,下面预览的时间跨度仅仅是两个月。大家可以根据需要在 reCalendar 定制你自己需要的特殊日子和时间跨度等等元素。

Loader Loading…
EAD Logo Taking too long?

Reload Reload document
| Open Open in new tab

关于 reMarkable 2 的一些总结和分享就先到这了。我还会继续开发这个玩具,有新的再分享。😁

Hammerspoon : macOS 界的瑞士军刀

相信很多朋友在自己的电脑里面都有特殊化定制。这可能也是为什么很多人都非常不喜欢配置新电脑或者重装系统。

譬如我自己,有一波应用用来提升我的电脑使用体验,如下是不完全统计:

应用用途
SizeUp可以通过快捷键将窗口吸附在屏幕的上下左右以及死角。对于大屏幕非常有用,左侧文档,右侧代码
Pap.er可以自动定时将美丽的风景画设置成我的壁纸
Stretchly定时遮挡屏幕以强制休息
Lexico.com牛津英英词典官方网站
Cheatsheet用来查看当前应用的所有快捷键
New Terminal Here在当前目录打开终端
我的常用工具列表

这些工具在提升我的用机舒适度的同时,也在我造成了一些困扰,尤其重装系统和换电脑的时候。有时候是因为软件开始收费了,我需要寻找一个免费的替代品;有时候是因为停止维护很久不再支持最新系统了,我需要寻找一个新的替代品。直到有段时间,Mac 似乎都和我有仇一般,半年更换了三台电脑。忍无可忍之下,我开始寻找一个能够替代这些小工具的方法,终于被我找到了一个 MacOS 上的瑞士军刀: Hammerspoon 。

类似的工具其实也不少,但是 Hammerspoon 在定制化能力和易用性上找到了一个平衡点。用户只需要稍微了解一下 Lua 基础就能很快上手利用 Hammerspoon 通俗易懂的 API 和完整的 Spoon 库实现一些定制化功能。我在毫无 Lua 基础的情况下,很快通过阅读文档完成了几个功能的定制。

我们可以用官方提供的 Spoon 来定制,以窗口控制为例

-- 我们可以通过引入 WinWin 这个 Spoon 来轻松实现
hs.loadSpoon("WinWin")

-- 判断 WinWin 是否正常载入,并根据窗口控制效果配置热键
if spoon.WinWin then
  -- 文本提示能完美支持符号字符,这对后面设置热键列表非常有用,后面会说
  -- Side
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "left", "Window ⬅", function() spoon.WinWin:moveAndResize("halfleft") end) 
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "right", "Window ➡", function() spoon.WinWin:moveAndResize("halfright") end) 
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "up", "Window ⬆", function() spoon.WinWin:moveAndResize("halfup") end) 
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "down", "Window ⬇", function() spoon.WinWin:moveAndResize("halfdown") end) 
  -- Corner
  hs.hotkey.bind({"shift", "alt", "ctrl"}, "left", "Window ↖", function() spoon.WinWin:moveAndResize("cornerNW") end) 
  hs.hotkey.bind({"shift", "alt", "ctrl"}, "right", "Window ↘", function() spoon.WinWin:moveAndResize("cornerSE") end) 
  hs.hotkey.bind({"shift", "alt", "ctrl"}, "up", "Window ↗", function() spoon.WinWin:moveAndResize("cornerNE") end) 
  hs.hotkey.bind({"shift", "alt", "ctrl"}, "down", "Window ↙", function() spoon.WinWin:moveAndResize("cornerSW") end) 
  -- Stretch
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "C", "Window Center", function() spoon.WinWin:moveAndResize("center") end) 
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "M", "Window ↕↔", function() spoon.WinWin:moveAndResize("maximize") end) 
  -- Screen
  hs.hotkey.bind({"alt", "ctrl"}, "right", "Window ➡ 🖥", function() spoon.WinWin:moveToScreen("next") end) 
  -- Other
  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "/", "Window Undo", function() spoon.WinWin:undo() end) 
end
Code language: Lua (lua)

是不是很简单?只需要定义几个热键,选择一下需要的控制效果就 OK 了。

我们也可以利用 Hammerspoon 的 API 自定义 Spoon ,以 BreakTime 为例

为了实现这个,我们需要定时使用一个页面或者图片遮挡整个屏幕以提示电脑前的家伙应该短暂休息一下了。

首先,我们可以轻松通过 hs.timer 开启一个定时器:

obj.Timer = hs.timer.new(60, refresh)
obj.Timer:start()
Code language: Lua (lua)

然后,我们可以通过 hs.webview 创建一个遮挡这个屏幕的网页,页面中展示一个半透明的图片以达到透明效果

function makeBrowserOfBreakTime ()
  local screen = require"hs.screen"
  local webview = require"hs.webview"

  local mainScreenFrame = screen:primaryScreen():frame()
  browserFrame = {
     x = mainScreenFrame.x,
     y = mainScreenFrame.y,
     h = mainScreenFrame.h,
     w = mainScreenFrame.w
  }

  local options = {
      developerExtrasEnabled = true,
  }

  -- local browser = webview.new(browserFrame, options):windowStyle(1+2+4+8)
  local browser = webview.new(browserFrame, options):windowStyle(1+2+128)
    :closeOnEscape(true)
    :deleteOnClose(true)
    :bringToFront(true)
    :allowTextEntry(true)
    :transparent(true)

  return browser
end
Code language: Lua (lua)

接下来,我们就可以通过定时器来创建 browser:show() 和销毁 browser:delete() 页面来实现遮挡效果。

function refresh()

  obj.curTime = obj.curTime + 1
  if obj.curTime > obj.microbreakInterval then
  
    obj.curMicrobreakCount = obj.curMicrobreakCount + 1
    if obj.curMicrobreakCount > obj.microbreakCount then
      hs.alert.show(obj.breakTime .. " minute break starts")

      local browser = makeBrowserOfBreakTime();
      browser:url("file://" .. hs.spoons.scriptPath() .. "BreakTime.html?time=" .. (obj.breakTime * 60 - 1)):show()
      hs.timer.doAfter(obj.breakTime * 60, function()
        if browser ~= nil then 
          browser:delete(); 
        end 
      end)

      obj.curMicrobreakCount = 0
    else
      hs.alert.show(obj.microbreakTime .. " second microbreak starts")

      local browser = makeBrowserOfBreakTime();
      browser:url("file://" .. hs.spoons.scriptPath() .. "BreakTime.html?time=" .. (obj.microbreakTime - 1)):show()
      hs.timer.doAfter(obj.microbreakTime, function() 
        if browser ~= nil then 
          browser:delete(); 
        end 
      end)
    end

    obj.curTime = obj.curTime - obj.microbreakInterval

  end
end
Code language: Lua (lua)

最后,我们就可以像前面调用 WinWin 一样载入 BreakTime 这个自定义 spoon 并启动就可以了。

我们还可以定义任务栏菜单和提升一下用户体验

以 BreakTime 为例,我们可以将下一个休息时间显示出来

BreakTime 菜单提示
BreakTime 菜单提示

不光如此,我们还可以把所有在 Hammerspoon 定义的热键都展示出来。

所有热键的菜单提示
所有热键的菜单提示

由于他对符号字符支持的很完美,所以你在菜单里面可以设置很多有意思的提示,是不是很赞?😄

obj.menubar:setTitle("⌨️")
obj.menubar:setTooltip("Hot Key Info")

local hotkeys = hs.hotkey.getHotkeys()
local menuItem = {}
  
for key, value in pairs(hotkeys) do  
  local item = { title = value["msg"] }
  table.insert(menuItem, item)
end 

obj.menubar:setMenu(menuItem)Code language: Lua (lua)

除以上这些,Hammerspoon 还有很多东西值得探索。各位如果有兴趣的话,可以用我的定制随便试试。我也在不断摸索完善我自己的定制。

其实不光这些小工具,每次重新安装我的常用 App,例如 VS Code、Plex、Sublime Text、VIM 等等,也是个很痛苦的事情。我现在通过维护一套 homebrew 列表来快速完成大多数的 App 安装。😂

ImgCache 0.2.1 发布

我终于给 ImgCache 进行了一次版本更新。当时我承诺会在下一个版本完成对 HTTPS 图片链接的支持,没想到一晃就是十二年。我还在开博日志中吐槽了一下自己。现在我终于信守承诺,在新版本中搞定了 😂。Better late than never!

时隔 12 年发布 ImgCache 0.2.1
时隔 12 年发布 ImgCache 0.2.1

当然除了支持了 HTTPS 图片链接的缓存,也升级了 Snoopy 类,更新了代码格式使得更符合代码规范和 README,还修复了两个有意思的小 bug。

第一个 bug 是对 SVG 图片后缀名的错误提取。这几天在开发 HTTPS 支持功能的时候,我用 PNG 测试,功能完全正常。但是我偶然使用了一个 SVG 图片链接来作为测试,发现缓存的图片无法在前端展示,看了下源代码缓存的图片文件后缀名不是 .svg 而是 .svg+xml 。

坦白说,我已经完全想不起来,在这套十二年代的远古代码里面我是通过什么方式来确定缓存图片文件后缀名的。简单翻了下代码,原来是用图片链接请求 HTTP 头字段中的 Content-Type 去判断。通常图片链接的 HTTP 头字段中都包含类似于 Content-Type: image/png 的信息来定义 MIME 类型,我们只要通过 / 前部分来判断是否是图片,后半部分来判断图片类型直接得到后缀名就可以了。本来插件也基本上就是自用,所以当时估计就随便测试了常用格式,发现一切 OK,就发布版本了。

但是很奇怪的是,SVG 的 Content-Type 并不是 image/svg 而是 image/svg+xml,所以当我把提取出 svg+xml 作为后缀名的时候,前端展示就出现了问题。

根据 Wikipedia 中,SVG 中的矢量图信息是以 XML 格式保存的,所以本质上 SVG 是一个 XML 文件。这样子一切就说得通了。

Scalable Vector Graphics (SVG) is an XML-based vector image format for two-dimensional graphics with support for interactivity and animation. The SVG specification is an open standard developed by the World Wide Web Consortium (W3C) since 1999.

SVG images are defined in a vector graphics format and stored in XML text files. SVG images can thus be scaled in size without loss of quality, and SVG files can be searched, indexed, scripted, and compressed. The XML text files can be created and edited with text editors or vector graphics editors, and are rendered by the most-used web browsers.

https://en.wikipedia.org/wiki/Scalable_Vector_Graphics

于是我不得不对 SVG 的 Content-Type 做一个预处理后再作为后缀名来使用。其实存在这个问题的还不止 SVG,打算在后续版本中处理掉。

第二个 bug 是因为 WordPress 编辑器生成的 IMG 标签是不关闭的,且最后的属性和标签末尾之间缺少空格,如下:

<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="http://nginx.org/nginx.png">
Code language: HTML, XML (xml)

而当时的我,认为所有的 HTML 标签都应该是关闭了,且最后的属性和标签结束应该是存在空格的,如下:

<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="http://nginx.org/nginx.png" />
<img src="http://nginx.org/nginx.png" ></img>
Code language: HTML, XML (xml)

基于这个认知,之前版本中提取图片 URL 的正则鲁棒性不够,所以无法成功从这个 WordPress 生成的 IMG 标签中提取出正确的图片 URL,使得插件认为图片链接都是无效的,就停止了缓存。😅

关于这个版本的故事就先扯的这里。至于下个版本,我可能会加上对缓存图片过期时间的自定义能力。由于这个插件当时主要是为了用来缓存 Feedburner 订阅数之类的图片,更新频率比较高,所以我把过期时间固定在了 1 小时,即 3600 秒。现在想想,过期时间能自定义肯定是更合理一点。

好了,下个版本见吧。