Category Archives: Coding

Keeping Xiaomi Doorbell Videos Forever with Home Assistant and Xiaomi Miot Auto

Recently I got a Xiaomi Smart Doorbell 3S. This doorbell is quite interesting! It doesn’t support local storage, and all the videos are kept only in Xiaomi Cloud. The problem is that, without a cloud subscription, Xiaomi Cloud only keeps the videos from the last 72 hours and then deletes them.

To save battery, the doorbell stays asleep most of the time. It only wakes up and records in three cases:

  1. Someone is detected at the door
  2. Someone presses the doorbell
  3. Someone tries to forcibly remove the doorbell

So keeping the videos only in the cloud is a fairly good compromise between saving battery and letting users check the videos at any time. If the videos were stored locally, users couldn’t watch them while the doorbell is asleep. And because the doorbell sleeps, it obviously doesn’t provide RTSP or ONVIF for 24/7 continuous recording either. As it happens, Synology Surveillance Station only came with two free camera licenses on my NAS, and both are already used by my two cameras.

Before ordering it, I thought Home Assistant, with its huge community, would already have an integration that keeps the doorbell videos. After it arrived, I found that Xiaomi Miot Auto, the integration I use to manage my Xiaomi Home devices, doesn’t support this at all 😅.

If you want something done, do it yourself. My basic idea was to use Home Assistant and Xiaomi Miot Auto to automatically download and decrypt the cloud videos, then store them on my Synology NAS.

After a bit of research, I found that each time the doorbell wakes up, it records a clip of up to 30 seconds. Each clip is tagged with an event type, such as Pass for someone passing by, Pass:Stay for someone staying at the door, and Pass:Bell for someone pressing the doorbell.

Here is a quick note on the whole process and the issues I ran into along the way.

No Need to Reinvent the Wheel

Why build on Home Assistant and Xiaomi Miot Auto? Because they already handle the Xiaomi account login and token refresh, and the doorbell’s camera entity also shows the time of the latest event (motion_video_time), the event type (motion_video_type), and an m3u8 playback URL (stream_address).

Since the wheel already exists, I didn’t want to reinvent it. Building directly on top of this integration is clearly the most efficient and simplest option. It looks like all I need to do is get stream_address and convert it with ffmpeg, right?

I hoped so.

Four Issues

Issue 1: The m3u8 Is Encrypted

Requesting stream_address returns an HLS playlist — an m3u8 file, which is basically a list that points to several video segments — and every piece is encrypted with 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)

The idea isn’t complicated: fetch the 16-byte key from the URI in #EXT-X-KEY, then use that key and the IV to decrypt each segment with AES-128-CBC. But testing every detail one by one is essentially a very boring process of trial and error.

To be honest, I handed this part over to AI. I used Google Antigravity, let it do the boring trial and error, and just took the results 😂.

Issue 2: A 4-Second Video

After decryption worked, I naturally treated the segments as standard MPEG-TS (.ts) chunks — the usual fragments of one long video — appended all the decrypted bytes into a single file /tmp/test_doorbell.ts, and then remuxed it into an MP4 with ffmpeg:

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

The same clip is more than 14 seconds long in the Xiaomi Home app, but what I got was only 4 seconds, plus a screen full of Non-monotonous DTS errors 🤦‍♂️:

[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

When I printed the first 8 bytes of each decrypted segment, every one of them was 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

In short, these segments are MP4 files, not TS. Why? An MP4 file is made of boxes. Each box starts with a 4-byte big-endian size, followed by a 4-byte type written in ASCII:

  • 00 00 00 20: the box size, 0x20 = 32 bytes.
  • 66 74 79 70: the box type in ASCII, 0x66 = f, 0x74 = t, 0x79 = y, 0x70 = p, so it reads ftyp.

ftyp (File Type Box) is normally the very first box of an MP4 file. A TS chunk would start with the sync byte 0x47 instead.

In other words, each roughly three-second segment from Xiaomi is a complete, standalone MP4 file, not the usual TS or fMP4 chunk. Each segment’s timestamps start from zero, so joining them byte by byte is bound to be a mess.

The fix is to save each segment as its own temporary file and losslessly stitch them together with ffmpeg’s concat demuxer. -c copy means ffmpeg copies the video as-is, without converting it again:

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

The result is the full 2K HEVC (H.265) video with no re-encoding, and it’s really fast.

Issue 3: 60-Second Polling Misses Clips

Xiaomi Miot Auto polls every 60 seconds and only picks up the latest event. If someone stays at the door and triggers two clips in a short time, the next Home Assistant poll only sees the second one, and the first one is missed for good.

So besides accepting the URL passed from Home Assistant, the script uses the credentials that Xiaomi Miot Auto stores in .storage/xiaomi_miot/auth-*.json to call Xiaomi Cloud’s event list API (common/app/get/eventlist) directly. It checks the recent events, compares them with the videos already on the NAS, and downloads any that are missing.

Another benefit is that token refresh is handled entirely by Home Assistant and Xiaomi Miot Auto, so the code needs no account information at all.

Issue 4: The Clip Is Still Being Uploaded

The doorbell records up to 30 seconds per event and uploads to the cloud while recording. If you download a clip as soon as the event shows up, you will probably only get the first one or two segments.

The fix is simple and blunt:

  1. In the automation, wait 35 seconds after motion_video_time changes before calling the script.
  2. In the script, check whether the m3u8 contains #EXT-X-ENDLIST. If not, the clip is still being uploaded, so wait 10 seconds and fetch it again.

Architecture

The whole flow looks roughly like this:

//www.plantuml.com/plantuml/png/RP9FRzGm4CNl_XH3J-qXAhJygJYWXknA22cX9KBbK6db9hCRgxOdiYUxohTdRCYYXLxzU_FyvjctaNNWoLdrYWTq2FeG2mhQxr2fKP3fpAWzmzcAA3oXjMUW8wnEsK-ZoHceB4rzvjMBl3R4K4vCMLEVQhwK6JdDXdmMbFy6E7XBkiS0RHVCo5dKdegQiit7ZL8hA3v11H_XnvZ607qNvf6nXuWxXvxZepz_nf5XIm4-NC50KuWNoXADiDvZcE7zQz5svFke0Eh5xuwi1QiP8mFknKXW1ONGMgYZjC1cRtga3Hkfz18Y1zHke_i-O8mAVQ-oM_cYlXjI_Rqg5yc3DWpNxuvPCcW5n44UPDEHSreisoNW_PJ2vsXWJMJrErsQ-8vTEiqi_bbLTmDqrepWvNd8Wtm4a1vXnhJPqNNf7zprT0Do8JpZV5mzcQ__QtYPJtI5cS9vkMwBDrStHVMvkdZqoIYoOGbnC3p0Tkj6t2s3ZGosUezGtxs5fnbwgJttrnxB2OPH73HF4Rwstvg_lJLIxNPEAs485DIj3AQl_Wi
Sequence diagram of the doorbell video archiving flow
  1. The doorbell uploads encrypted segments to Xiaomi Cloud (kept for 72 hours).
  2. Xiaomi Miot Auto polls every 60 seconds and updates the doorbell entity’s attributes.
  3. When motion_video_time changes, a Home Assistant automation waits 35 seconds and calls the downloader script through shell_command. The script also runs when Home Assistant starts and every 30 minutes as a safety net.
  4. The script reads the latest auth file, queries the event list, downloads the m3u8 and the key, decrypts the segments, and joins them with ffmpeg.
  5. Videos are saved by date under /volume1/surveillance/Xiaomi Doorbell 3S/YYYY-MM-DD/ with names like 2026-10-30_21-20-29_Pass-Stay.mp4. I chose this folder on purpose, because it’s also where Surveillance Station stores the camera footage, so I can check all the videos in one place.
  6. The script writes its status to a JSON file, and a Home Assistant sensor reads it and sends a notification when something goes wrong.

The Downloader Script

The downloader is a Python script that runs on the Python 3.13 bundled with the Synology Home Assistant package. requests, cryptography, and the micloud library used by Xiaomi Miot Auto are all built in, so there’s nothing extra to pip install.

The heart of the script is these few decryption lines: decrypt each segment with AES-128-CBC, then strip the PKCS7 padding:

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

In configuration.yaml, add the allowed directory, two shell_command entries (one for event-triggered downloads and one for scheduled syncs), and a command_line sensor that reads the status JSON:

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)

To keep the post short, I won’t paste the full code here, but here’s a quick note on the parameters used:

  • --url, --time, --type: the playback URL, time, and event type of the latest event.
  • --limit: how many of the most recent events to fetch from Xiaomi Cloud’s event list and check. Clips already on the NAS are skipped, and only the ones not yet downloaded are downloaded. The event-triggered run uses 8 and the scheduled sync uses 15. Both are kept small so the script finishes within the 60-second shell_command timeout.
  • --backfill: catch-up mode. The script pages backward through the event list, --limit events per page, and checks every clip still kept in Xiaomi Cloud. It takes a while, so I recommend running it by hand rather than from an automation.

automations.yaml

The automation has three triggers: an event update, Home Assistant startup, and every 30 minutes. The event branch waits 35 seconds first:

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)

There’s also a second automation that has Home Assistant send a notification when the status sensor turns to ERROR. I won’t go into detail here.

Backfilling Existing Cloud Clips

Once everything is configured, run the script once by hand with --backfill to pull all the clips from the last 72 hours in Xiaomi Cloud. The logs look roughly like this:

[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)

Lessons Learned

  • Don’t assume the segments in an m3u8 are TS. Check the first 8 bytes of a decrypted segment. Once you see ftyp, the problem is obvious.
  • Home Assistant’s shell_command has a 60-second timeout. For automatic runs, keep --limit (the number of events checked per run) small; for big catch-ups, run --backfill by hand.
  • Xiaomi Cloud stalls now and then. Adding retries to every HTTP request saves you from a lot of random failures.
  • File permissions on Synology. Manual tests over SSH run as your own admin account, while Home Assistant runs as the HomeAssistant user, so when you hand the code over to Home Assistant and something goes wrong, check the permissions on the log, the status file, and the folders first.

Now every time the doorbell is triggered, the video lands on my NAS automatically, and I no longer worry about it disappearing after 72 hours. Not many people come and go at my door, and 30-second clips are small, so I didn’t set any maximum retention period in the script.

That’s all for now.

reMarkable 2: One Highly Customisable New Toy

Got the budget on my birthday, and the Black Friday deal was not bad, so I finally got a new toy: the reMarkable 2. 😂

reMarkable 2
reMarkable 2

Before I got this tablet, several guys recommended this to me, and what finally convinced me to buy it was no more than its extreme paper-like writing, thinness, lightness, design, etc. But after I experienced this reMarkable 2, I found its usefulness and customizability were totally beyond my expectation. The most surprising thing is that reMarkable OS is based on Linux and the manufacturer generously exposes the SSH port and root password.

During the year-end holiday, I went through this tablet a lot and would take notes with this post.

Functionality Completeness

Language Issues

The reMarkable 2 does not support non-Latin characters, so CJK (Chinese, Japanese, and Korean) characters are displayed as squares, as below.

Squares
Squares

One compromised solution is to use PDF only if possible, but we still need to ensure that all the necessary fonts are embedded in the PDF documents. If any CJK characters apply fonts not embedded in the documents, they will still be displayed as squares.

Luckily, the OS of reMarkable is based on Linux, and the SSH and the root password are available, so we can easily install the CJK fonts. Take this page as a reference for installing the 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)

And the Noto Sans font can support CJK characters now.

Type Diversity

The reMarkable 2 only natively supports PDF and EPUB, but more is needed. Toltec, this community-maintained repository of free software for the reMarkable tablet, helps. With Toltec, we can install KOReader into reMarkable 2. Thanks to the powerful format compatibility, we can read documents in almost all major formats with the reMarkable 2.

## Use opkg to install remux and koreader after installing the Toltec
opkg update
opkg upgrade
# remux
# Launcher that supports multi-tasking applications 
opkg install remux
# KOReader
# Ebook reader supporting PDF, DjVu, EPUB, FB2, and many more formats
opkg install koreaderCode language: Bash (bash)

There is one thing that needs to be highlighted. The shelves of the reMarkable and KOReader do not share; we can surely use cloud storage to solve this. I will share this later. Before installing the Toltec, please ensure that the current Toltec release can support the OS version of your reMarkable tablet.

The Toltec repository includes lots of exciting software. Take your time to go through it if you are interested.

Article Management and Document Management

The reMarkable provides a browser extension. With this extension, users can convert web pages into EPUB documents and put them on the reMarkable’s shelf with one click. It is really convenient, but it is hard for me to get used to it.

  1. The format of the EPUB document content converted by the extension looks strange.
  2. Years of using Pocket as a read-it-later application make me not plan to switch to reMarkable. And I don’t want to put all my saved web pages on the reMarkable’s shelf.

So I started automating pushing the saved pages from Pocket to reMarkable, and I recalled n8n, an open workflow automation platform.

I heard about n8n before, and I tried this time and found it was really convenient. I deployed n8n on the NAS, then created this workflow quickly. You may see that the UI of workflow on n8n is really visual and user-friendly.

Pocket 2 Google Drive
Pocket 2 Google Drive

I use a scheduler to trigger the workflow below.

  1. Get the saved timestamp of the last run, and retrieve the pages saved after the last run. The API for retrieving the saved pages has an optional parameter: since. With this parameter, Pocket will only return items modified since the given UNIX timestamp.
  2. If the list is not empty, the workflow will use the OneSimpleAPI to convert the web pages into PDF documents and upload them to Google Drive one by one.
  3. Update the saved timestamp to present.

The above workflow nodes of OneSimpleAPI and Google Drive are natively supported by the n8n and are very easy to call.

These tips may reduce some of the confusion when you are creating a similar workflow:

  1. The since parameter of Pocket API uses the last modified time as a reference. If you would like to push some outdated saved pages to Google Drive, you just need to make a slight change to them, such as tag modification and resaving it again, and these pages will be pushed to Google Drive.
  2. When you trigger a deletion of a saved page, Pocket will update the status of this page to 2 before the page is totally removed. So please ensure all the saves whose status is 2 will be filtered out to prevent unnecessary uploading.

The Google Drive node supports two authentication methods: OAuth 2 and ServiceAccount. OAuth 2 requires that your callback URL contains a domain name other than an IP. For security, my NAS blocks all direct external access and only allows VPN access, so I have not bound my NAS to any domain name. So anyone whose NAS is in the same situation as mine, please prefer ServiceAccount. Primarily the service account is not your Gmail account, so please grant the R/O permission to the Google Drive folder of your reMarkable documents to the service account.

Permissions Granted to Service Account
Permissions Granted to Service Account

Actually, I can directly push the documents onto the reMarkable shelf, but I don’t think this is a good idea. The limitation of documents’ count on the reMarkable shelf can help me focus more on the current readings before I pick new readings from Google Drive. I will not let too many documents mess up my reMarkable shelf.

As I mentioned, the shelves of KOReader and reMarkable 2 don’t share, so using Google Drive to store my pending readings can make the shelves tidier. It is also easy for KOReader and reMarkable 2 to retrieve the documents wirelessly.

Publication management

Ages before, I started to use Calibre to manage publications, such as novels, books, comics, papers, etc. Then I moved the library of Calibre to NAS and ran Calibre Web in a Docker container so that I could manage the publications from any device.

Calibre Web is not an official product of Calibre, and there is more than one fork on GitHub. I prefer the image managed by linuxserver.io. If you have never used Calibre before deploying Calibre Web, you will need a new library file (metadata.db) to initiate the configuration. You can install Calibre to create a library or download it from here.

Besides the publication management via web UI, Calibre Web also supports the OPDS catalog.

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

In this way, I can search for publications from the OPDS catalog.

# 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)

If items exist, the OPDS catalog will send an XML containing publication information, including book names and links, as a response.

<?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)

Based on this, I created the workflow below to upload the document I need to Google Drive automatically.

Query the books and upload the needed books to Google Drive.
Search for the book and upload the needed books to Google Drive.

I use the Webhook node to pass the query string to the OPDS catalog to search for the publications.

Others

There are many functionalities worth going through on reMarkable, such as templates. I am amazed by the completion of reMarkable’s support for PDF, especially the support for the internal links. Many E-ink tablets almost don’t support internal links in PDF documents. Even to support some significant internal links such as tables of contents, they compromise toggle a menu to help users locate the pages instead of jumping during the document reading.

So we can generate templates with reCalendar. This template can help us plan and summarize in a year-month-day dimension. To prevent loading too long, the timespan of the preview below is only two months. You can also tweak the template by adding special dates, modifying the timespan, etc., with reCalendar.

Loader Loading…
EAD Logo Taking too long?

Reload Reload document
| Open Open in new tab

Here is all about reMarkable 2 I learned. I will keep digging and will share more. 😁

Hammerspoon: one Swiss Army Knife on macOS

I may believe that most computers contain unique customizations, which may be why most people hate reinstalling the operating systems or setting up a new computer.

Such as my laptop, there are a bunch of tools that improve my experience using a computer. Here is a non-exhaustive list:

AppUsage
SizeUpCan easily attach the windows to the sides or corners of the screen. If the screen is big enough, you may put the documentation on the left and IDE on the right.
Pap.erIt can periodically and automatically set a beautiful sightseeing image as your wallpaper.
StretchlyTimely covers your screen to make you have a rest.
Lexico.comThe official site of the Oxford dictionary.
CheatsheetIt can show you the list of the selected application’s hotkeys.
New Terminal HereOpen a terminal with the current folder path.
One non-exhaustive list of my favorite tools

Besides improving my experience in using a computer, these tools also cause a few inconveniences, especially when I need to reinstall the OS and set up a new laptop. I have to look for an alternative because sometimes one is no longer free of charge, or sometimes one no longer supports the latest OS. This didn’t bother me much until I changed my Macbook Pro thrice in half a year. So I tried to find a way to minimize the number of tools I relied on, and I found Hammerspoon. I can say this is really a Swiss Army Knife on macOS.

Actually, there are some similar tools, but Hammerspoon finds the best balance between usage simplicity and functionality complexity. The users only need a little knowledge of Lua before customizing with Hammerspoon. Under the situation of no experience in Lua, I quickly finished the customizing based on the official documentation.

We can customize with the official spoons, such as windows manipulation.

-- We can quickly implement this with the WinWin spoon
hs.loadSpoon("WinWin")

-- Check whether WinWin is loaded and bind hotkeys to different directions.
if spoon.WinWin then
  -- Supporting symbol characters in the message of Hammerspoon is quite helpful. We will talk about this later. I will talk about this later.
  -- 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) 
endCode language: PHP (php)

It is pretty easy, right? Only several hotkey bindings to functionality are enough.

We can also create spoons with the API, such as BreakTime.

To implement this, we need to periodically use a page or image to cover the whole screen to make the guys in front of the PC take a rest.

Firstly, we can quickly start a timer with hs.timer:

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

Then, we can create a page covering the whole screen with hs.webview. Using a transparent image to make the page translucent is also not bad.

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
endCode language: PHP (php)

Next, we can show and remove the cover with browser:show() and browser:delete() periodically with the timer.

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
endCode language: JavaScript (javascript)

Load the BreakTime spoon in the same way to load the WinWin spoon, and start it. Done!

We can also customize the taskbar menu to improve the user experience.

Take BreakTime as an example. We can show the timeline of the next break.

BreakTime's Menu
BreakTime’s Menu

Besides this, we can also show all the hotkeys defined with Hammerspoon.

Menu showing all the hotkeys
Menu showing all the hotkeys

Hammerspoon supports symbol characters quite well, so users can make the menu items more attractive. Isn’t this amazing? 😄

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: JavaScript (javascript)

Besides all the above, Hammerspoon still has much that needs going through. I am also keeping going through to make my customization more additional. Here’s mine.

BTW, not only reinstalling these tools, but I also need to reinstall all of the common applications such as VS Code, Plex, Sublime Text, VIM, etc. This is also terrible. Now I am maintaining a tap list containing the applications with homebrew. 😂

ImgCache 0.2.1 Released

I promised 12 years ago that I would enable the HTTPS support in the next release. And now, I have finally released a new version of ImgCache. I also made fun of myself for this in my first blog post. Now I have made my word and implemented this in this version. Better late than never 😂

Released ImgCache 0.2.1 after 12 years
Released ImgCache 0.2.1 after 12 years

Besides enabling the HTTPS support, I upgraded the Snoopy class, updated the code format and the README file, and fixed two interesting bugs.

The first one is about the suffix extraction of the cache images. When I coded for the HTTPS support functionality, I passed the test with PNG images. But when I tried with SVG images, the cached images didn’t show on the pages because the suffix of the cached image file was not .svg but .svg+xml.

To be honest, I could hardly remember how I confirmed the suffix in this 12-year-old ancient code. So I went through the code and found the suffix was defined based on the Content-Type inside the HTTP header fields when accessing the image’s link. The HTTP header will contain a field named Content-Type like this Content-Type: image/png when we access the image’s URL. This field helps clients confirm the MIME type of the response, so we can use the former part before / to verify whether this is an image and use the later part after / to get the suffix. Because I was the one who mostly used this plugin, I only tested with several typical image types and released it when I passed all the cases

But the Content-Type of SVG is image/svg+xml not image/svg. The cached image doesn’t show on the pages because we set .svn+xml as the suffix of the cached image file.

It says on Wikipedia that SVG is an XML file containing all the vector graphic information. That makes sense.

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

Therefore, I must split the Content-Type’s value to confirm the suffix. Actually, SVG is not the only one, and I will handle the other types in the following release.

The second bug was due to the IMG tag generated by the WordPress’ new editor. The image tags generated by the WordPress’ new editor are not closed. And there is no space between the last attribute and the tag end as below.

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

At that moment, in my opinion, all the HTML tags should be closed, and there must be a space between the attribute and the end of the tag as below.

<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)

Based on this understanding, the REGEX for URL extraction in the previous version was not robust enough to handle this. So the URL extracted was invalid, and the plugin would stop caching this image. 😅

So much for the stories about this version. Users may be able to set customized expiration times of cached images in the next version. I used this plugin to show the Feedburner subscribers’ count images. Because the count kept changing, I set the expiration time as one hour, which is 3600 seconds in the code. Now the usage changes, a customized expiration time may make sense.

Alright! Let’s see the next release.