Winter Project: SDR

It’s still September, but winter will come soon. I learned about Software Defined Radio years ago, but the equipment was very expensive or limited. The software ecosystem wasn’t that large either.

However that has changed in the meantime: an Airspy is US$169 and the SpyVerter $49 and that combo can scan anything from DC to 1.8GHz.

First order was watching the spectrum at various wavelength via SDR#:

Scanning 433MHz band

As you can see it detects nicely when I pushed a button on the 433.92MHz remote control. Interestingly there’s a slight 3kHz miss-tuning for the small remote compared to the RF bridge:

Red line: 433.92MHz.
Lower signal: RF Bridge, upper signal: remote control

And I can listen to FM radio (very poor quality since the antenna I have is for 433MHz band):

FM Radio

To use any other bands beside 433MHz I’d need different or adjustable antennas. Something for later to worry about.

Decoding the RF Remote Control

Recognizing and decoding digital signals is surprisingly easy with the right tools. rtl_433 can decode a lot of standard devices, so it was my first attempt. Compiling was a bit more complex than I though due to dependencies. It was well enough documented though. After that’s done, time to decode some signals!

rtl_433 -d airspy -Y autolevel -M level -R 0 -v -A

This shows output like this for every button press and usually several times repeated:

Analyzing pulses...
Total count:   25,  width: 290297.52 ms         (72574379 S)
Pulse width distribution:
 [ 0] count:    1,  width: 289230564 us [289230564;289230564]   (72307641 S)
 [ 1] count:   16,  width: 11380 us [11372;11456]       (2845 S)
 [ 2] count:    8,  width: 33640 us [33636;33644]       (8410 S)
Gap width distribution:
 [ 0] count:    9,  width: 11708 us [11684;11728]       (2927 S)
 [ 1] count:   15,  width: 34020 us [33880;34284]       (8505 S)
Pulse period distribution:
 [ 0] count:    1,  width: 289242292 us [289242292;289242292]   (72310573 S)
 [ 1] count:   23,  width: 45380 us [45256;45660]       (11345 S)
Pulse timing distribution:
 [ 0] count:    1,  width: 289230564 us [289230564;289230564]   (72307641 S)
 [ 1] count:   25,  width: 11496 us [11372;11728]       (2874 S)
 [ 2] count:   23,  width: 33888 us [33636;34284]       (8472 S)
 [ 3] count:    1,  width: 100004 us [100004;100004]    (25001 S)
Level estimates [high, low]:    126,     -1
RSSI: -42.3 dB SNR: 42.0 dB Noise: -84.3 dB
Frequency offsets [F1, F2]:     121,      0     (+0.5 kHz, +0.0 kHz)
Guessing modulation: Pulse Width Modulation with sync/delimiter
view at
Attempting demodulation... short_width: 11380, long_width: 33640, reset_limit: 34288, sync_width: 289230560
Use a flex decoder with -X 'n=name,m=OOK_PWM,s=11380,l=33640,r=34288,g=0,t=0,y=289230560'
pulse_slicer_pwm(): Analyzer Device
bitbuffer:: Number of rows: 1
[00] {24} fb 0e 7b  : 11111011 00001110 01111011

Note the line with the flex decoder: those numbers vary slightly. Take the average for s, for l and r. Use the most common values for g and y. Use t to add some margin of timing error. I have 2 RF devices: a small remote control and a RF-Wifi bridge. Both can send the same signals, but their timing is slightly off:

  • Remote: s=13378±5, l=33638±10, r=34929±10
  • Bridge: s=11635±40, l=34198±10, r=34768±10

So I took the average, added some slack and that worked for both devices:

$ rtl_433 -d airspy -Y autolevel -M level -R 0 -v -X 'n=remote1,m=OOK_PWM,s=11500,l=34000,r=34900,g=0,t=800,bits=24'
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
time      : 2022-09-23 11:46:45
model     : name         count     : 1             num_rows  : 2             rows      :
len       : 0            data      : ,
len       : 24           data      : fb0e7b
codes     : {0}, {24}fb0e7b
Modulation: ASK          Freq      : 433.9 MHz
RSSI      : -48.3 dB     SNR       : 36.0 dB       Noise     : -84.3 dB
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
time      : 2022-09-23 11:46:45
model     : name         count     : 1             num_rows  : 2             rows      :
len       : 0            data      : ,
len       : 24           data      : fb0e7b
codes     : {0}, {24}fb0e7b
Modulation: ASK          Freq      : 433.9 MHz
RSSI      : -48.3 dB     SNR       : 36.0 dB       Noise     : -84.3 dB
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

Note that the shown code fb0e7b is unique to a single button. The other 3 buttons send different 24 bit codes.

Other 433.92MHz Devices

I was hoping to find more devices on that band, but I guess I need a really good antenna on a roof to detect them.

Frequency Usage in Japan

Here is the most useful general frequency usage plan.

OverTheWire – Security Wargames

I wish I had seen this a long time ago: are a set of challenges in the area of security: buffer overflows, command injection, web server security, plus fun Linux command line skills.

I learned a lot about how to detect and exploit buffer overflows and shellcode in the Narnia wargame. Good fun and educational too.

Natas is nice since it’s using a web server while the other ones use ssh. The harder ones are…hard, but there’s solutions all over the web in case you get stuck.

All in all, highly recommended to see how easy it is to exploit badly written code and how to write code which does not repeat those known security problems.

Dart & Pool

Tip of the day: If you use Dart and want to use the Pool library, expect not much help from Google when searching for those keywords: you get the expected results. Adding “future” or “async” helps.

Anyway, the point of this post is a small example how to use a Pool to run commands in parallel, but not too many concurrently.

import 'dart:io';
import 'package:pool/pool.dart';
import 'package:test/test.dart';

Future<ProcessResult> runCommand(String command, List<String> args) {
  return, args);

void main() {
  test('Run 20 slow date commands', () async {
    var pool = Pool(5);
    List<Future<ProcessResult>> results = [];
    for (var i = 0; i < 20; ++i) {
      results.add(pool.withResource(() => runCommand('./date_slow', ['+%N'])));
    await pool.done;
    for (var process in results) {
      var res = await process;

./date_slow is a simple script which returns something on STDOUT and finishes in 2s:

date $*
sleep 2

What happens then is that Pool(5) creates a pool with 5 slots. The first for loop tries to run 20 commands though and it’ll queue all 20 immediately, but only 5 at most will run. The rest simply waits until it’s their turn.

pool.close() stops any new entries and the await pool.done simply waits until the pool is closed and all jobs are executed.

The 2nd for loop (with the print() statement) uses await to get the ProcessResult from the Future<ProcessResult> which returns and which is store in the results[] list.

The outcome here is that if the pool is 5 slots large, and each command runs for 2 seconds, the complete set of 20 jobs runs in 20/5*2=8 seconds. If I make the pool 10 slots large, it’ll run in 20/10*2=4 seconds and in case of 20 or more slots, it’ll be 2 seconds. And it’ll never run more processes than slots are available.

Why I need this? I have a list of URLs from few to hundreds which I need to query. While I can query many concurrently, each instance takes up some non-trivial amount of memory since it’s an external program. Currently at 100 concurrent calls, it uses up all memory on a 16GB RAM notebook. While there are many ways to work around this (do one at a time is the safest and slowest one), using a pool is perfect: it runs many commands in parallel, but I can limit the number of how many run in parallel.

Twitter Does Not Like Me Anymore

All the buzz about Twitter and Elon made me look at my Twitter profile. I saw the missing date of birth, so I added it. Now my account is locked:

Here I became less than 13 years old

I am quite confident I put in a 19xx year in there, so I should be at least 22 years old, but I cannot check that now since my account is locked.

At this point I wonder if I should care. At worst in 13 years I’ll get my access back. If you send me a message in Twitter, you might have to wait 13 years for a reply…

Update 2022-07-31: Well, they like me again. It seems I managed to become 13 quite fast:

And days later I aged enough to be over 13!

Fake Webpages And How To Detect Them

Being in Japan and knowing how much good knifes cost, when I saw an advertising of hand made Japanese knifes…let’s say that it looked suspicious from the start. Their web page did look good though. But still…suspicious. Starting with the name “Huusk” which is as much non-Japanese as I can imagine.

Then the commonly seen signs to create some time pressure:

70% discount!

and the popups about sales happening right now:

Someone in Kyoyo Kita-ku Minamimitakamine bought it!

and yet that number of knifes left does not change:

7 left. Always.

Typical scammer stuff of creating a sense of “Buy it now! Before you start to think about it!”

I looked once up a countdown on another web page which counted from about 3h down to zero…so I let it run out. 3h later it showed negative time. And if you reload the page, it goes back to about 3h! This one is less obvious, but I was curious how the popup gets populated as it tries to imply a “Someone near you bought something, so it must be good!” Is it hard-coded like the timer, or dynamically pulled from somewhere? Turns out it is statically populated:

  "orders": [{
    "first_name": "hidetaka",
    "city": "funakosityo yokosuka",
    "country": "JP",
    "topText": "hidetaka from Funakosityo yokosuka, JP made a purchase.",
    "bottomText": "X1 Huusk Knife Sold!",
  }, {
    "first_name": "Indrajith",
    "city": "Itakoshi,Hinode",
    "country": "JP",
    "topText": "Indrajith from Itakoshi,Hinode, JP made a purchase.",
    "bottomText": "X1 Huusk Knife Sold!",
  }, {
    "first_name": "YOICHI",
    "city": "Nagano inaba",
    "country": "JP",
    "topText": "YOICHI from Nagano inaba, JP made a purchase.",
    "bottomText": "X1 Huusk Knife Sold!",
  }, {
    "first_name": "TAKASHIGE",
    "country": "JP",
    "bottomText": "X3 Huusk Knives Sold!",
  }, {
    "first_name": "YASUHIRO",
    "city": "Ootaku SINKAMATA",
    "country": "JP",
    "topText": "YASUHIRO from Ootaku SINKAMATA, JP made a purchase.",
    "bottomText": "X3 Huusk Knives Sold!",
  }, {
    "first_name": "hiroshi",
    "city": "nakagawashimatsunoki",
    "country": "JP",
    "topText": "hiroshi from Nakagawashimatsunoki, JP made a purchase.",
    "bottomText": "X4 Huusk Knives Sold!",
  }, {
    "first_name": "EIJI",
    "city": "MATUBARACITY",
    "country": "JP",
    "topText": "EIJI from MATUBARACITY, JP made a purchase.",
    "bottomText": "X4 Huusk Knives Sold!",
  }, {
    "first_name": "Kyle",
    "city": "Okayama",
    "country": "JP",
    "topText": "Kyle from Okayama, JP made a purchase.",
    "bottomText": "X3 Huusk Knives Sold!",
  }, {
    "first_name": "Syouji",
    "city": "Yokohamasi",
    "country": "JP",
    "topText": "Syouji from Yokohamasi, JP made a purchase.",
    "bottomText": "X4 Huusk Knives Sold!",
  }, {
    "first_name": "Toshio",
    "city": "hamamatsu",
    "country": "JP",
    "topText": "Toshio from Hamamatsu, JP made a purchase.",
    "bottomText": "X3 Huusk Knives Sold!",
  "image": "",

The Terms and Conditions page is suspicious too:

Hand-made knifes and they create a single size? Why would anyone limit themselves to a single size if they are hand-made anyway? Japanese love to have different knifes for different jobs, but this Japanese company does not?

If you look up the text of some of the user testimonials you’ll find another knife company “Kaitomi” which looks just the same. But their web page is not yet ready: Still references to Huusk. Oops! At least it sounds a bit more (fake) Japanese…

Oops…forgot to remove the Huusk stuff

And I even found the popular Lorem Ipsum text filler:

So it’s pretty clear that this is a scam.


I looked up Huusk expecting reviews like “Those knifes are not as good as the advertising suggested”, but I found something even better! And confirmed my findings. And there’s many more such pages are discussed! E.g. fake investment web pages who’ll take your money and predictably disappear.

The most interesting part of that web page however is that they basically explain what to look for:

  • DNS and company registration, country and date. Recently registered and claiming to be 20 years in business?
  • Actual location of the business: Japan? US? UK? Lithuania? Nigeria? Claiming or suggesting that they are from somewhere else?
  • Use of stock photos for “user testimonials”. Reverse image search can find stock photos.
  • Copy&paste user testimonials used in other places too.
  • Selling a “unique” product which is also sold on other places (eBay, Aliexpress, Taobao).
  • The intense attempt of creating a sense of urgency.

Very educational and I wish more people would be aware of those scam tricks.

TensorFlow on arm64

The VIM3L (Cortex A55 cores) I have has a NPU accelerator built-in. An interesting article about it is here, however before doing fancy NPU stuff, let’s get TensorFlow working first. Should be easy.

Famous last words. Turns out that “pip install tensorflow” does not work: on arm64 (AKA aarch64 AKA ARMv8) TensorFlow is not officially supported. So I had to compile it first.

Compiling TensorFlow described the compile process reasonable well. It is missing a lot of details though, so here is a more detailed walk-through. Start with a Ubuntu 20.xx image with an extra 70 GB disk for TensorFlow source code:

# One-time action: for the data disk, create a volume and a filesystem
# to mount under /data

sudo bash
pvcreate /dev/nvme1n1
vgcreate vg_data /dev/nvme1n1
lvcreate -L69G -n data vg_data
mke2fs -j /dev/vg_data/data
mkdir /data
echo -e '/dev/mapper/vg_data-data\t/data\text4\tdefaults\t0 1' >>/etc/fstab
mount /data
chown ubuntu:users /data
umount /data

sudo apt update
sudo apt -y upgrade
sudo reboot

After a reboot, you now have a /data of about 70GB.

sudo apt -y install build-essential python3 python3-dev python3-venv pkg-config zip zlib1g-dev unzip curl tmux wget vim git htop liblapack3 libblas3 libhdf5-dev openjdk-11-jdk

# Get bazel

chmod a+x bazel-4.2.2-linux-arm64
sudo cp bazel-4.2.2-linux-arm64 /usr/local/bin/bazel

# bazel uses ~/.cache/bazel

mkdir -p /data/.cache/bazel
ln -s /data/.cache/bazel ~/.cache/bazel

# Build a Python 3 virtual environment

python3 -m venv ~/venv
source ~/venv/bin/activate
pip install wheel packaging
pip install six mock numpy grpcio h5py
pip install keras_applications --no-deps
pip install keras_preprocessing --no-deps

# Get TensorFlow source

cd /data
git clone
cd tensorflow/
git checkout r2.8
cd /data/tensorflow


# Build Python package:

bazel build -c opt \
--copt=-O3 \
--copt=-std=c++11 \
--copt=-funsafe-math-optimizations \
--copt=-ftree-vectorize \
--copt=-fomit-frame-pointer \
--host_copt=-DRASPBERRY_PI \
--verbose_failures \
--config=noaws \
--config=nogcp \

# Build Python whl:

BDIST_OPTS="--universal" bazel-bin/tensorflow/tools/pip_package/build_pip_package ~/tensorflow_pkg

# And for tfjs:
# (see

bazel build --config=opt --config=monolithic //tensorflow/tools/lib_package:libtensorflow
# The result is at bazel-bin/tensorflow/tools/lib_package/libtensorflow.tar.gz

It does take a lot of time (about 2-3h for each the Python package and the tfjs-node library). When I tried 2 CPU and 8 GB RAM, some compiler runs were killed as they were running out of memory. 4 CPU and 16 GB RAM worked fine.Thus AWS m6g.xlarge recommended. m6g.large failed to build.

Using spot instances for the m6g.xlarge (regular $0.154/h, spot price $0.04/h) helped a bit to limit the financial impact.

Python and TensorFlow

It took me several tries:

  • When using Ubuntu 22.04 to compile TF, the resulting binary wanted GLIBC 2.35 which my VIM3L did not have. It had 2.31. It also used Python 3.10 to compile.
  • When using Ubuntu 20.04, it used Python 3.8 to compile. My VIM3L had Python 3.9. While GLIBC was fine, Python was not.
  • The created whl file could be loaded and used on the machine I compiled it on. No Python or GLIBC version problems here.

That covered all my Python needs. Now moving to the main target:

Node.js and TensorFlow

tfjs-node uses the library, so that should remove some of the CPython version problems I have seen. Compiling was easy now: is spot on.

The biggest problem was to make Node.js understand to not use the non-existing arm64 pre-compiled library, but instead use the one I created. The instructions in the above link did not explain in enough details how to make this work. In hindsight it’s easy, but it took some tries to make me understand it. In short:

  • Do an “npm install –ignore script”
  • Add a file scripts/custom-binary.json into the modules directory for @tensorflow/tfjs-node (this gave me the hint)
  • Run “npm install” in the tfjs-node directory
  • That will download the tensorflow library archive
  • Now do the “npm install” where your application is (which is the only “npm install” you’d usually do)
❯ npm install --ignore-script
❯ pushd .
❯ cd node_modules/@tensorflow/tfjs-node/scripts
❯ cat >>custom-binary.json <<_EOF_
  "tf-lib": ""
❯ cd ..
❯ npm install
> @tensorflow/tfjs-node@3.16.0 install
> node scripts/install.js

* Downloading libtensorflow
[==============================] 3685756/bps 100% 0.0s
* Building TensorFlow Node.js bindings
❯ popd
❯ npm install


As a benchmark I modified slightly server.js from the tfjs-examples/baseball-node to not listen to the port which means after the training it’ll exit. Then run this on the VIM3L (S905D3), my ThinkCentre m75q (Ryzen 5), and my HP T620 (GX-420CA) once with CPU backend (tfjs) and once with the C++ TF library (tfjs-node):

Amlogic S905D3-N0N @ 1.9GHzcpu803
Amlogic S905D3-N0N @ 1.9GHztensorflow189
AMD Ryzen 5 PRO 3400GE @ 3.3GHzcpu122
AMD Ryzen 5 PRO 3400GE @ 3.3GHztensorflow36
AMD GX-420CA @ 2GHzcpu530
AMD GX-420CA @ 2 GHztensorflow119
All running Node.js 16.x

I did not expect Node.js to be just 4 times slower than C++. Really impressive. Still, using tfjs-node makes a lot of sense. While on x86_64 this was not an issue, with above instructions it’s doable on arm64 too.

Dart HTTPS Server

In my previous post I used a simple HTTPS server written in Node.js and I was curious how that would look like in Dart. And it’s very short too:

import 'dart:io';

Future<void> main() async {
  var chain = Platform.script.resolve('cert.pem').toFilePath();
  var key = Platform.script.resolve('key.pem').toFilePath();
  var context = SecurityContext()
  var server = await HttpServer.bindSecure(InternetAddress.anyIPv4, 8080, context);
  await server.forEach((HttpRequest request) {
    print("${request.method} ${request.uri.path}");

Works just as well as the Node.js counterpart.

TP-Link Kasa KC120 – Streaming without Kasa

The main problems I have with IoT devices are:

  • They might send data home without me knowing about it
    • But I can monitor their traffic pattern and if they send home way more data than expected, I could disconnect them
  • They might be vulnerable to exploits
    • But I can put them on a separate VLAN at home so they don’t see other devices unless I allow it (via firewall rules)
    • I can sometimes update firmware (definitely a problem after few years)
  • They stop to work when the company turns off their servers
    • I am able to use them without Internet connectivity

Most Kasa products I own (power switches) are supported by various projects like Home Assistant or python-kasa, so turning on my Kasa power switch on my own is a simple task. Same for my LIFX light bulbs there’s even an official API.

The TP-Link KC120 camera however does not have any supported local API and contrary to my expectation, it does not support a local stream mode via a web browser interface. I can watch a live (and local) video stream via the Kasa application on the phone, but that functionality is at the mercy of TP-Link. I don’t like that.

Following are the steps to have local streaming (resp. recording) for the KC120. And with that it’s possible to do whatever I’d like to do with the stream: publishing on the Internet, processing via OpenCV, local archiving etc.


python-kasa does not support the camera, so you won’t see it during a normal discovery:

❯ kasa
No host name given, trying discovery..
Discovering devices on for 3 seconds
== Plug Three - HS105(JP) ==
        Device state: OFF

        == Generic information ==
        Time:         2022-05-03 11:37:55 (tz: {'index': 90, 'err_code': 0}
        Hardware:     2.1
        Software:     1.0.3 Build 210506 Rel.161924
        MAC (rssi):   10:27:F5:XX:XX:XX (-62)
        Location:     {'latitude': XX.0, 'longitude': XX.0}

        == Device specific information ==
        LED state: True
        On since: None

        == Modules ==
        + <Module Schedule (schedule) for>
        + <Module Usage (schedule) for>
        + <Module Antitheft (anti_theft) for>
        + <Module Time (time) for>
        + <Module Cloud (cnCloud) for>

== Plug One - HS105(JP) ==
        Device state: OFF

        == Generic information ==
        Time:         2022-05-03 11:37:55 (tz: {'index': 90, 'err_code': 0}
        Hardware:     1.0
        Software:     1.5.8 Build 191125 Rel.135255
        MAC (rssi):   B0:BE:76:XX:XX:XX (-54)
        Location:     {'latitude': XX.0, 'longitude': XX.0}

        == Device specific information ==
        LED state: True
        On since: None

        == Modules ==
        + <Module Schedule (schedule) for>
        + <Module Usage (schedule) for>
        + <Module Antitheft (anti_theft) for>
        + <Module Time (time) for>
        + <Module Cloud (cnCloud) for>

But the camera shows up with an additional -d switch, although it’s being ignored since the tool does not know how to handle it:

❯ kasa -d
No host name given, trying discovery..
Discovering devices on for 3 seconds[DISCOVERY] ('', 9999) >> {'system': {'get_sysinfo': None}} 3 seconds for responses...
[...] to find device type from {'system': {'get_sysinfo': {'err_code': 0, 'system': {'sw_ver': '2.3.6 Build 20XXXXXX rel.XXXXX', 'hw_ver': '1.0', 'model': 'KC120(EU)', 'hwId': 'CBXXXXD5XXXXDEEFA98A18XXXXXX65CD', 'oemId': 'A2XXXX60XXXX108AD36597XXXXXX572D', 'deviceId': '80XXXX88XXXX76XXXX88XXXXX3AXXXXXXXXXXXB6', 'dev_name': 'Kasa Cam', 'c_opt': [0, 1], 'f_list': [], 'a_type': 2, 'type': 'IOT.IPCAMERA', 'alias': 'Camera', 'mic_mac': 'D80D17XXXXXX', 'mac': 'D8:0D:17:XX:XX:XX', 'longitude': XX, 'latitude': XX, 'rssi': -38, 'system_time': 1651545748, 'led_status': 'on', 'updating': False, 'status': 'configured', 'resolution': '720P', 'camera_switch': 'on', 'bind_status': True, 'last_activity_timestamp': 1651545210}}}}: Unable to find the device type field!

Important fields here are the deviceID and via the MAC address, you can find out what IP address the camera has (if you use DHCP). In my case is the camera’s IP address.


nmap shows only port 9999 open which is the known TP-Link debug port. But there’s more ports:

❯ sudo nmap -p-
Starting Nmap 7.80 ( ) at 2022-05-03 11:51 JST
Nmap scan report for kc120.lan (
Host is up (0.012s latency).
Not shown: 65531 closed ports
9999/tcp  open  abyss
10443/tcp open  unknown
18443/tcp open  unknown
19443/tcp open  unknown
MAC Address: D8:0D:17:XX:XX:XX (Tp-link Technologies)

Nmap done: 1 IP address (1 host up) scanned in 9.28 seconds

And with that port information I found this article: It’s about a slightly different camera model, but since the ports patch, maybe more does.

I followed it, however I could not get the authentication working: the Kasa account password as per article did not work. Time to do the ARP spoofing to see what the Android app uses to authenticate! Geistless did a great job explaining the steps he took.

My overall plan:

  1. Redirect the traffic from the Kasa app on the phone to my Linux machine (via arpspoof)
  2. Redirect the incoming HTTPS traffic to my HTTPS server (via iptables)
  3. Print the URL and headers for incoming HTTPS traffic which arrives at my HTTPS server


The dsniff package contains arpspoof:

❯ sudo apt install dsniff
❯ sudo setcap CAP_NET_RAW+ep /usr/sbin/arpspoof

My HTTPS Server

While the original author had a https server as part of his Rust learning, I created a NodeJS version. But first we’ll need keys. Self-signed is fine:

❯ openssl genrsa -out key.pem
❯ openssl req -new -key key.pem -out csr.pem
❯ openssl x509 -req -days 999 -in csr.pem -signkey key.pem -out cert.pem
❯ rm csr.pem

Now the simple HTTPS server listening on port 8080:

const https = require('https');
const fs = require('fs');

const options = {
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')

https.createServer(options, function (req, res) {

Some IP traffic routing rules to redirect all incoming TCP traffic on enp1s0 for ports 10443, 18443 and 19443 to port 8080:

❯ sudo iptables -t nat -A PREROUTING -i enp1s0 -p tcp --dport 10443 -j REDIRECT --to-port 8080
❯ sudo iptables -t nat -A PREROUTING -i enp1s0 -p tcp --dport 18443 -j REDIRECT --to-port 8080
❯ sudo iptables -t nat -A PREROUTING -i enp1s0 -p tcp --dport 19443 -j REDIRECT --to-port 8080
❯ sudo sysctl net.ipv4.ip_forward=1

Now run the https server and watch it display the URL and the headers for an incoming request on port 19443:

❯ node ./https.js

and to test, on another machine I ran:

$ curl -k -u admin:abc 'https://t621.lan:19443/test?a=3&b=5'

and this is the output of my https server:

  host: 't621.lan:19443',
  authorization: 'Basic YWRtaW46YWJj',
  'user-agent': 'curl/7.68.0',
  accept: '*/*'

The basic authentication is base64 encoded. To decode:

❯ echo YWRtaW46YWJj | base64 -d

So that works. Now putting it all together.

  • Start the Kasa app on the phone. Make sure the KC120 is enabled and can display a live video stream. Stop the stream.
  • Have the iptables redirect rules in place. And IP forwarding in the kernel.
  • Start the HTTPS server.
  • Run arpspoof. is the phone’s IP which runs the Kasa application. is the IP of the KC120.
❯ arpspoof -i enp1s0 -t
7c:d3:a:xx:xx:xx 38:78:62:xx:xx:xx 0806 42: arp reply is-at 7c:d3:a:xx:xx:xx
  • On the mobile app, try to connect to the video stream of the KC120 again
  • You should now see some output of the HTTPS server:
  authorization: 'Basic aXXXXXXXXXXXXXXXXM=',
  connection: 'keep-alive',
  'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 10; H8296 Build/52.1.A.3.49)',
  host: '',
  'accept-encoding': 'gzip'

And then I finally had the authentication string the camera wanted!

❯ echo 'aXXXXXXXXXXXXXXXXM=' | base64 -d

Turns out that the password to use was not the Kasa password: it’s a longish string of hex digits. That might be a KC120 specialty or it might depend on the firmware version. I cannot say since I have no KC100, but whatever the password is, it’s possible to find out relatively easily using above approach.

The Result: Local Streaming!

I can connect to the video stream! And with very little CPU usage too.

--ignore-content-length \
"" \
--output - | ffmpeg -hide_banner -y -i - -vcodec copy kc120stream.mp4

To change resolution, change it in the Kasa app. 1920×1080 (1.4Mb/s), 1280×720 (850kbit/s) and 640×360 (350kbit/s) are possible.


  • There is no audio coming from the camera. Audio works on the Kasa app.
  • It would also be nice to understand how to change the configuration of the camera (e.g. change resolution), but it’s ok to set them once via the Kasa app.
  • What options do the parameter video, audio and resolution support?

Moving Two Things

Learning G-Code is quite interesting: it’s really old, really simple, and yet surprisingly complex if you want to get CNC cuts 100% perfect. Luckily my use-cases are much simpler, but I still would like to move 2 axis concurrently: e.g. move along the x-axis from 0 to 400mm, and rotate the a axis from 0 to 180 degrees so that they start and end together.

Adding a Servo

Step 1 is to connect a servo, which was not as easy as I thought since there’s no dedicated servo connector I can readily use on the DLC32. Luckily there are plenty pins to get GND, 5V and a PWM signal. This is the section I added to the config.yaml:

    steps_per_mm: 10
    max_rate_mm_per_min: 100000.0
    acceleration_mm_per_sec2: 100000.0
    max_travel_mm: 180.0
    soft_limits: true
      cycle: 0
      mpos_mm: 0
      positive_direction: false
        pwm_hz: 50
        output_pin: gpio.25
        min_pulse_us: 1000
        max_pulse_us: 2000

IO25 is readily available on the EXP1 connector, so I used 5V, GND and IO25 from EXP1:

EXP1 and EXP2 (unused with FluidNC)

Now I can use G-Code like this:

  • G0A90 to move the servo to the neutral position 90° (assuming 1ms=0°, 2ms=180°). The actual movement depends on the servo. Most move only 90° in total.
  • G1X300A10F200 now does:
    • Move to x=300mm and rotate a to 10° at a feed-rate of 200 mm/minute. If x=100 at the start, this movement will take 1 minute.
    • During the movement, a ?<ENTER> will show the current positions:
[...about 30 seconds later...]