
You have reached the personal homepage of an entity commonly known as derf / derfnull / Birte Friesel. Hi! đ
If you are looking for the more professional side of me, you may take a look at my Publications (see below) or head directly to my work homepage: Dr. Birte Kristina Friesel @ UniversitĂ€t OsnabrĂŒck
Resources
- Photography
- Projects
- Publications (see also: ESS, ORCID, DBLP, Google Scholar)
- Recipes
- Repositories (partial mirrors: Codeberg, GitHub)
- Weblog (Fediverse Microblog: @derf@social.skyshaper.org)
- Whatever
Contact
You can reach me by E-Mail (derf@finalrewind.org) and on IRC (derf0 @ OFTC, hackint). My PGP key for E-Mail encryption is 64FE6EC0 55560F9E F13A3044 19E6E524 EBB177BA. I occasionally post stuff on the Fediverse (@derf@social.skyshaper.org).
The remainder of this page duplicates a curated sub-set of projects and the latest blog entries.
Projects
> dbris 'Eichlinghofen H-Bahn, Dortmund' 'Dortmund Hbf' 19.01. 16:51 (00:21) 17:12 . Bus S Bus 440 â Oespel S-Bahnhof, Dortmund 16:51 (+1) ab Eichlinghofen H-Bahn, Dortmund 16:56 (+1) an Oespel S-Bahnhof, Dortmund FuĂweg 46m (â 3 min.) S 1 â Dortmund Hbf . 17:01 (+5) ab Dortmund-Oespel 2 17:12 (+2) an Dortmund Hbf 7
> hafas 'Eichlinghofen H-Bahn, Dortmund' 'Dortmund Hbf' 00:15 Schw-B HB5 (0:03) S 1 Schw-B HB5 â UniversitĂ€t S-Bahnhof, Dortmund 21:51 ab Eichlinghofen H-Bahn, Dortmund 21:55 an UniversitĂ€t S-Bahnhof, Dortmund Walk 37m (approx. 3 minutes) S 1 â Dortmund Hbf 21:58 ab Dortmund UniversitĂ€t: 2 22:06 an Dortmund Hbf: 4
> efa Essen Martinstr DĂŒsseldorf Hbf 14:34 ab Essen Martinstr.: Bstg. 1 StraĂenbahn 108 Essen Altenessen Bf Schleife 14:38 an Essen Hauptbahnhof: Bstg. 1 14:47 ab Essen Hauptbahnhof: 2 R-Bahn RE11 (RRX) DĂŒsseldorf Hbf 15:24 an DĂŒsseldorf Hbf: 10
> dbris-m 'Bochum Hbf' 06:39 ( +7) ICE 843 Berlin Hbf 5 06:39 ( +7) ICE 853 Berlin Hbf 5 06:51 (+19) S 1 Essen Hbf 7 06:37 ( +1) ICE 527 MĂŒnchen Hbf 3 Zug fĂ€hrt abweichend mit nur einem Zugteil. Die Wagen 31 - 39 entfallen.
> hafas-m 'Hamburg Dammtor' 13:49 ( +1) RE 7 Flensburg 3 13:49 ( +1) RE 7 Kiel Hbf 3 13:49 S 5 Buxtehude 2 13:50 ( +4) Bus 5 Nedderfeld, Hamburg 13:50 U 1 Ohlstedt, Hamburg
> efa-m -s VVO Dresden Hbf 13:40 ( -2) 5 66 Lockwitz 13:41 3 3 . Wilder Mann 13:44 4 3 . CoschĂŒtz 13:44 6 66 Freital-Deuben 13:46 ( +4) 6 360 Kurort Altenberg Bahnhof 13:46 5 360 Dresden AmmonstraĂe / Budapester StraĂe 13:48 ( +1) 1 7 * Weixdorf 13:51 1 10 . Tolkewitz 13:52 Gl.10 RE3 Hof Hbf
News
In early July/August 2026, dm2lct and I spent a tad over a week in the Tatra Mountains, specifically in the Western Tatras (Tatry Zachodnie) with some excursions into the High Tatras (VysokĂ© Tatry). This is a direct follow-up to last year's Karpacz / Giant Mountains trip â we were hungry for more mountain hiking, and oh boy did we get what we asked for. But more on that later.
Just like last year, we booked a room in a town right at the foot of the mountains: Zakopane, which sits at a comfortable 700 to 950 metres of elevation, nestled between the ƻywiec Beskids in the northeast, the Gorce Mountains in the northwest, and the Western Tatras in the south. The former two are also nothing to sneeze at: Babia Góra (ƻywiec Beskids) reaches 1,700m, and Turbacz (Gorce Mountains) still 1,300m. However, with to up to 2,500m of peaks within walking distance of Zakopane (and a tad more beyond), the Tatras definitely win.
We got a room in Zakopane Bystry, right at the edge of the Tatra National Park (TPN / TatrzĂĄnski Park Narodowy) and situated at 940m of elevation, making the start of our trips much easier at the cost of occasionally having to venture into town for dinner in the evening. Overall, I'd say this was a good idea â being able to skip an hour of walking through town in the morning (and the associated 100 to 200 m of climb) is very nice. The only downside is that Zakopane is very much situated in the Western Tatras and not that close to the High Tatras. But it's not like those didn't have enough to offer to fill one or two weeks â plus, there is a frequent (every 5 to 10 minutes) and reasonably affordable bus service to Dolina BiaĆki / Morskie Oko, and thus the High Tatras.
Also, Zakopane is a very pretty (and clean!) city, including a special local architecture style that reminds me a bit of Swiss alp huts. So if you've ever got a day to fill outside of the mountains due to inclement weather or because you simply need a pause, you likely won't be bored.
Tatra Mountains
Compared to the Giant Mountains, the Tatras are everything turned up to eleven: higher peaks, steeper climbs, and overall more demanding terrain and trails. We quickly learnt that we should aim for about 20 km of mountain hiking per day and not the 25 to 30 km we were used to from the Giant Mountains. Whereas trails in the Western Tatras are typically rated SAC T2 (âmountain hikingâ), mountaintop and ridge line trails in the High Tatras are frequently classified as T3 (âdemanding mountain hikingâ) to T5 (âdemanding alpine hikingâ). By comparison, most trails in the Giant Mountains fall into the T1 to T2 categories (if classified at all â even the ridge line is pretty tame), with very few exceptions that are rated T3 or T4.
In any case, there isn't much that compares to climbing a 2,300m high peak in order to look down into the 1,500m valley (and 1,700m ridge) that you traversed just a few days ago, with Zakopane (<1,000m) in the distance, and the Slovakian valley holding Poprad also visible across the next ridge. I'm fairly sure that I'll return sooner rather than later. After all, I've now got Orla PerÄ on my bucket list.
The Tatras are also very very beautiful, in large parts thanks to TPN and its very sparse set of hiking routes and mountain huts. Most of the time, you'll have untouched nature as far as the eye can see, ranging from forests over mountain seas and grasslands all the way to barren mountain peaks.
I'll publish blog posts detailing our individual trips over the next few months. In the meantime, you can also explore photo galleries of our individual trips.
- 28.07.2026: Nosal (close to Zakopane)
- 29.07.2026: Kopa Kondracka â Kasprowy Wierch (along the main ridge of the Western Tatras)
- 30.07.2026: Hala GÄ sienicowa / MaĆy KoĆcielec (valley and lakes at the conjunction of Western Tatras and High Tatras)
- 31.07.2026: Sarnia SkaĆa (Tatra foothils close to Zakopane â there was a thunderstorm warning on that day)
- 01.08.2026: Dolina Suchej Wody (a valley in the eastmost part of the Western Tatras)
- 02.08.2026: Dolina Olczyska (featuring a spring with an impressive amount of flow, and another thunderstorm warning)
- 03.08.2026: Jaskinia MroĆșna / Jaskinia Mylna (caves, rain, and slippery rocks)
- 04.08.2026: Morskie Oko â Ćwinica (a day in the High Tatras, and my personal highlight of the trip)
- 05.08.2026: StanikĂłw Potok / Dziura (some more Tatra foothills to wind down a bit)
This blog post is just a collection of how we got there, what I think of Zakopane itself, and how we returned.
Arrival
Compared to Germany, train travel in Poland is⊠special, at least as far as I am aware. If your journey involves multiple operators (e.g. KD and PKP), you'll need separate tickets for each of them, meaning that passenger rights only apply if you have a change of trains within the authority of a single operator.
In our case, there were a few PKP connections from WrocĆaw to Zakopane, but we had to use DB and KD trains to get to WrocĆaw in the first place. We got up at silly o'clock in the morning in order to minimize the risk of missing a connection, and luckily, everything worked out:
- Trilex RE1 Dresden Hbf (05:24) â Zgorzelec (06:54)
- KD D10 Zgorzelec (07:00) â Legnica (08:10)
- KD D11 / âMalachitâ Legnica (08:39) â WrocĆaw GĆĂłwny (09:24)
- PKP IC âFaĆatâ WrocĆaw GĆĂłwny (10:10) â Bytom (12:05 +11)
- PKP TLK âOsterwaâ Bytom (12:32) â Zakopane (16:39)
The TLK (formerly âTanie Linie Kolejoweâ / âcheap train linesâ, nowadays âTwoje Linie Kolejoweâ / âyour train linesâ) carriage did not have air conditioning and slightly cramped eight-seat compartments, but you could open the window, so that was pretty much fine. You just gotta accept that no matter which PKP train you take, toilet paper and paper towels will likely be out way before the end of the journey.
Getting from Dresden to Zakopane, all the way in the southeastern end of Poland, cost us a total of 46 ⏠(23 ⏠per person):
- 0 ⏠Dresden Hbf â Zgorzelec (Deutschlandticket),
- 69 zĆ (â 16 âŹ) Zgorzelec â WrocĆaw GĆĂłwny (KD website), and
- 127.50 zĆ (â 30 âŹ) WrocĆaw GĆĂłwny â Zakopane (PKP website).
We booked our PKP ticket a month in advance, which likely helped keep the price down.
Zakopane
I'll let the Wikipedia page do the talking. All I can add is that I really enjoyed the stay there, and that we also had some pretty good food. As a matter of fact, the only aspect that proved somewhat challenging was finding a shop that sells good postcards and not just fridge magnets.
Similar to Karpacz, most restaurants close between 21:00 and 22:00, so you should keep that in mind when planning your hikes. On the upshot, most will still happily accept customers half an hour before closing, so if you're not planning to have a three-course meal you're probably going to be fine. Just keep in mind that Polish cuisine is rather meat-heavy, so it pays off to check the menu beforehand.
I can specifically recommend the following places:
- TuChleb Bistro i Wypieki, Droga Na Bystre 4A: excellent bread (with a wide range of uncommon variants and one daily special), and also a great selection of sweet and savory breakfast pastries.
- Eko Chatka, Droga Oswalda Balzera 21: comfy café on the outskirts of town
- MaĆa Szwajcaria, Hr. WĆadysĆawa Zamoyskiego 11: excellent swiss food (at non-swiss prices!), and as an added bonus, they also serve dark Czech beer. We visited them twice, once for Bernese Rösti (sort of like a hash brown), and once for Fondue.
- KryjĂłwka Italiano, Hr. WĆadysĆawa Zamoyskiego 4: very good Pizza. You may have to wait for a while if you do not have a reservation.
- Thai Spot, Henryka Sienkiewicza 21: Thai food in a somewhat alternative (but not overly hipster-y) setting, and with a Thai chef.
- Indian Taste Zakopane, Nowotarska 19: Indian chefs, Indian clientĂšle, and appropriately good (and, if you want, spicy) Indian food.
The city is also large enough to have a municipal bus network â but not large enough for interval time tables, it seems. Some stops are served just every few hours, and the interval between departures can vary wildly over the day. Apart from that, the bus network worked well for us. Tickets are affordable (6 zĆ for a single trip or so), and buses largely on time.
There is also a sprawling private bus network taking you towards destinations such as Dolina BiaĆki (access to Morskie Oko) or KoĆcialiska Valley (access to caves such as Jaskinia MroĆșna). Those don't really operate according to time tables, but in practice, the service is so frequent that it dosn't matter. They're also a tad more expensive, but not overly so. If I recall correctly, a trip from Zakopane to Dolina BiaĆki costs 15 zĆ (so less than 4 âŹ) per person.
TatrzĂĄnski Park Narodowy (TPN)
As usual, the Polish side of the Tatras is covered by a national park. Entry costs 16 zĆ (approx. 4 âŹ) per day, or 55 zĆ (approx. 13 âŹ) for a 7-dy pass. It co-finances trail maintenance and operation of the Tatra Volunteer Search and Rescue (TatrzaĆskie Ochotnicze Pogotowie Ratunkowe / TOPR), which will rescue you free of charge if you have an accident on the Polish side of the Tatras (and does so quite frequently). We bought our passes in advance; you can just add them to your Smartphone's wallet and be done. There are ticket booths at virtually any park entrance, and as long as you don't enter via Morskie Oko or KuĆșnice, you likely won't have to wait long if you decide to buy a ticket on the spot instead.
Departure
For our way back, we had the option of either returning the way we came or going via Praha. We opted for the latter, and used the opportunity to spend a few days there before returning to Germany.
- PKP EIP Zakopane (08:31) â KrakĂłw GĆowny (10:28 +8)
- EC 114 KrakĂłw GĆowny (11:19 +30) â Praha hlavnĂ nĂĄdraĆŸĂ (17:52 +30)
On that day, I learnt that EIP means âExpress InterCity Premiumâ and comes with free snacks even in second class. Nifty! It also manages to do Zakopane â KrakĂłw about twice as fast as the TLK train, likely due to a mixture of fewer stops, higher priority, and the fact that it's a Pendolino tilting train.
The EuroCity train, meanwhile, was standard PKP IC rolling stock and our carriage's air conditioning unit absolutely could not deal with the ambient temperatures. Halfway through the journey, we were already pretty well-done. At least ÄD staff (once we had crossed the border and the staff had changed from PKP to ÄD) tried adjusting the AC a bit and distributed free drinking water.
This part of the return trip cost us a total of 65 ⏠(35 ⏠per person):
- 97.90 zĆ (â 23 âŹ) Zakopane â KrakĂłw GĆowny (PKP website), and
- 992 KÄ (â 42 âŹ) KrakĂłw â Praha hlavnĂ nĂĄdraĆŸĂ (ÄD / MĆŻj vlak).
Again, we booked the PKP ticket a month in advance â considering the distance, it was still more expensive than the inbound journey. I guess that's an EIP surcharge or just chance.
Travel::Status::MOTIS v0.04
Travel-Status-MOTIS-0.04.tar.gz (signature)
- MOTIS: Use v6 API
- MOTIS::Stop: Add
->parent_idaccessor - MOTIS::Trip: Rename
->route_nameto->display_name(breaking change)
Travel::Status::DE::DBRIS v0.32
Travel-Status-DE-DBRIS-0.32.tar.gz (signature)
- Update rolling stock models and train names (patches by Lili Urban)
- Fix mis-detected rolling stock models for non-german carriages
Flashing Raspberry Pi OS with SSH Enabled
- Flash the SD card as usual
- Mount it
- Create /boot/ssh
- Place your user name and enrypted pasword in /boot/userconf
- Unmount it
For instance, assming that you wanted to create the user "derf":
pmount mmcblk0p1
touch /media/mmcblk0p1/boot/ssh
echo derf:$(openssl passwd -6) > /boot/userconf
pumount mmcblk0p1
Travel::Status::DE::IRIS v2.05
Travel-Status-DE-IRIS-2.05.tar.gz (signature)
- Update station and meta databases
Travel::Status::DE::DBRIS v0.31
Travel-Status-DE-DBRIS-0.31.tar.gz (signature)
- Handle unusually-formatted UIC carriage IDs when requesting carriage formation data.
Travel::Routing::DE::DBRIS v0.13
Travel-Routing-DE-DBRIS-0.13.tar.gz (signature)
- Fix each request being delayed for 10 seconds by Akamai BMP
- New dependencies:
HTTP::Messageâ„ 6.37 andIO::Uncompress::Brotliâ„ 0.004_002
Travel::Status::DE::DBRIS v0.30
Travel-Status-DE-DBRIS-0.30.tar.gz (signature)
- Fix each request being delayed for 10 seconds by Akamai BMP
- New dependencies:
HTTP::Messageâ„ 6.37 andIO::Uncompress::Brotliâ„ 0.004_002 - Update operators and train names (patches by Lili Urban and Thulmi)
Things about RawTherapee I wish I'd have known earlier
All of this is subjective, and your mileage may vary.
- There is a lot you can get out of seemingly over- or under-exposed images.
- Do not use it on a per-file level. Just open the entire directory and edit what you like.
- At least for an EOS M50, the camera's auto white-balance is probably better than trying to get it right manually. Unless you have a white card or wanna go for a special style, of course.
- Copy-pasting (parts of) post-processing configurations can be super helpful, but do be careful.
- Just play around with sliders until you like the result. It's art; there is no right or wrong.
- Some features (like Dynamic Range Compression) will look great on some and horrible on other photos.
- If highlight compression gives you pink image areas, try a different highlight reconstruction method.
- RawTherapee is very conservative about the EXIF tags it exports by defualt.
- .pp3 files are just ini files, and you can easily adjust them to, e.g., include more EXIF tags in the exported JPEG files.
Travel::Status::DE::DBRIS v0.29
Travel-Status-DE-DBRIS-0.29.tar.gz (signature)
- dbris-m: location search: show distances if GIS::Distance is available
- Update operator names (thanks to typingbeaver, Thulmi, and Lili Urban)
Travel::Routing::DE::DBRIS v0.12
Travel-Routing-DE-DBRIS-0.12.tar.gz (signature)
- Segment: adjust for bahn.de-internal API changes
Travel::Status::DE::DBRIS v0.28
Travel-Status-DE-DBRIS-0.28.tar.gz (signature)
- Location: adjust for bahn.de-internal API changes
Software-Defined FM Audio Transmission with ADI / PlutoSDR
ADI / PlutoSDR / PySDR are powerful tools for transmitting IQ samples. Here's how to transmit FM data with them, which can then be received, e.g., via a ham radio handset. I'll also try to explain how the whole transmission business works â however, my understanding of high frequency data transmission witchery is not that good (yet), so you'd better double-check anything you find here.

Note that emitting FM transmissions may be considered illegal depending on your jurisdiction, TX power, EIRP, and/or frequency band. This example is going to use the ham radio band, which requires you to have a license and mention your callsign in transmissions in all countries that I am aware of.
No lawyers or regulators were harmed in the making of this blog post (I do have a valid ham radio license and call sign. DF7LUX meowing here!).
Software Setup
See the pysdr.org documentation for a detailed how-to. In my case (Debian unstable), installing the required dependencies (libad9361-dev, libaio-dev), activating a Python3 virtualenv, and running the following commands within it was sufficient.
git clone --branch v0.0.14 https://github.com/analogdevicesinc/pyadi-iio.git
cd pyadi-iio
pip3 install --upgrade pip
pip3 install -r requirements.txt
pip install setuptools scipy
python3 setup.py install
Basics
PySDR / PlutoSDR works with IQ samples and applies them to a carrier frequency by itself.
Its tx function takes a bunch of samples and blocks until they have been transmitted.
First, we need to define some basic parameters.
Here, we're going to use 100,000 samples per tx call and a 1 MHz PySDR sample rate.
Our FM transmission will use a maximum deviation of 12.5 kHz, which seems to work best for my Radioddity GD77.
We're also going to use the ham radio 144.6 MHz band for this test.
n_samples_per_tx = 100_000
sdr_sample_rate = 1_000_000
fm_deviation = 12_500
fm_carrier = 144_600_000
Now, in order to get started, we need to import some modules:
import adi
import numpy as np
import time
import scipy.io
import scipy.signal
Reading in a WAV file
Given a 16-bit signed WAV file, we can use scipy.io.wavfile to load it.
wav_sample_rate, data = scpiy.io.wavfile.read("some audio that includes your callsign.wav")
We're going to do a simple mono transmission, so in case the file holds stereo data, we'll throw away the second channel:
len(data.shape) == 2:
data = data[:, 0]
The WAV file holds 16-bit signed data (-32767 to 32768), whereas PySDR expects its samples to be 15-bit signed (-16383 to 16384) and the operations we're going to perform later assume floating point data in the range -1 to 1. Hence, we scale all samples to be within [-1, 1].
data = data / 2**15
Resampling
WAV files typically have a sample rate of 44.1 or 48 kHz; our SDR expects 1 MHz. So, for every sample in the WAV file, we must pass (roughly) 20 samples to the SDR.
We can use scipy.signal.resample to perform this transformation:
we have data.shape[0] input samples and must stretch the number of samples by the ratio of SDR sample rate to WAV sample rate.
samples = scipy.signal.resample(
data, int(data.shape[0] * (sdr_sample_rate / wav_sample_rate))
)
Note: this function seems to be quite memory-hungry. Alternatively, you can try the following snippet, which is not how it should be done but seems to be work well enough in practice.
# samples = np.repeat(data, sdr_sample_rate // wav_sample_rate)
Low-Pass Filter
The Nyquist-Shannon theorem states that a receiver operating with a specific sampling rate f can only reconstruct signals with frequencies of up to f/2 correctly. Any higher transmitted frequencies may cause aliasing, which may or may not cause unwanted effects for audio data.
As far as I understand it, this does not quite apply to FM transmissions â we've got 12.5 kHz FM deviation, but the SDR's sample rate, and thus the available bandwidth, is 1 MHz â or something between those two frequencies, anyway. In any case, applying a digital filter that throws away anything abore 12.5 kHz (and below 20 Hz for good measure) won't hurt. I'm not familiar with the intricacies of scipy.signal yet â this Finite Impulse Response filter works, but is copy-pasted and adjusted based on best guesses. So you might want to do your own research here.
bb = scipy.signal.firwin(41, (20, fm_deviation), pass_zero=False, fs=sdr_sample_rate)
samples = scipy.signal.lfilter(bb, [1], samples)
scipy.signal offers lots of different filtering methods; there may be better / more elegant ways of low-pass filtering.
Interlude: AM Transmission
Our audio signal is now almost ready for transmission. In fact, if all we wanted to do is AM (amplitude modulation), we would already be done. Our SDR uses IQ sampling, which means that each sample consists of two component: I (multiplied with the cosine of the carrier frequency) and Q (multiplied with the sine of the carrier frequency). The actual transmitted signal is x(t) = I · cos(2Ïft) + Q · sin(2Ïft), with I and Q belonging to individual samples in the TX buffer and f being the carrier frequency. With a sample rate of 1 MHz and a carrier of 144.6 MHz, each sample is used for 144.6 periods of the underlying carrier's sine / cosine waves.
In PySDR, IQ samples are represented as complex numbers I + jQ, where I is the real part and Q the imaginary one.
As samples contains purely real values, we obtain x(t) = I · cos(2Ïft), meaning that our samples I are modulating the amplitude of the carrier frequency -- or, in short, we're doing AM.
Generating IQ Samples for FM Transmission
In order to do FM, we must adjust the frequency of the carrier rather than its amplitude â the amplitude should always remain at its maximum value.
So, if samples[i] == -1, we want to shift the carrier down by -12.5 kHz to transmit a 144.5875 MHz signal.
If samples[i] == 0, we want to leave it as-is (144.600 MHz), and for samples[i] == 1, we want to shift it by +12.5 kHz to end up at 144.6125 MHz.
With IQ sampling, we can control only two aspects of the 144.6 MHz carrier transmitted by the SDR: its amplitude (see above) and its phase. We cannot adjust the frequency directly. However, we can control the phase for each sample separately.
If we transmit two consecutive samples with a different phase, this will affect the carrier's sine wave right at the transition between the two samples. For a positive phase difference, the sine will be slightly âtoo slowâ, i.e., essentially have a lower frequency than the configured carrier. For a negative phase difference, the sine will be slightly âtoo fastâ, i.e., essentially have a higher frequency than the configured carrier. This is called phase modulation, and the nice thing about it is that it can also be used for frequency modulation.
Interlude: Frequency Modulation via Phase Modulaton
Let's assume that we had an SDR sample rate that is identical to the carrier frequency, so, 144.6 MHz. Each sample is precisely as long as a single period of the carrier signal.
If we shift the phase by 180° after each period of the carrier signal, this effectively increases the frequency of the transmitted sine wave by 50% â so, rather than a 144.6 MHz signal, we'll be transmitting a 216.9 MHz one. We'll also be transmitting a (smaller?) frequency component that is 50% slower than our carrier, so, at 72.3 MHz.
If we shift the phase by 90°, we increase (or decrease, depending on direction) the frequency of the transmitted sine wave by 25%, yielding 108.45 or 180.75 MHz. In general, if we shift the phase by ϰ, we'll transmit 144.6 MHz + (Ï/360°) · 144.6 MHz.
Now, with the SDR, we cannot shift the phase after each period of the carrier â we can only do so every few dozen to hundreds of carrier periods. In this specific case, with 144.6 MHz carrier and 1 MHz sample rate, we can shift the phase every 144-odd carrier periods.
Even in this case, frequency modulation via phase moulation still works. Let's say that we shift the phase every ten carrier periods for now. Now, we're only doing this â± 50%â dance on 10% of all periods, and, as luck would have it, that also means that our frequency deviation is just 10% as high: we get 5% shift of the carrier frequency rather than the 50% we had before. So, a 180° shift will cause the frequency spectrum to split into two peaks: one is 5% higher and one 5% lower, i.e., one at 137.37 MHz and one at 151.83 MHz. A 90° shift will cause the frequency to increase / decrease by 2.5%, and so on.
Generally speaking: A phase shift of ϰ every n carrier periods will cause the transmitted frequency to change by (Ï/360°) · (1/n) · 144.6 MHz.
And even more generally speaking, since n = carrierFrequency / sampleRate: A phase shift of ϰ will cause the transmitted frequency to change by (Ï/360°) · (1/(carrierFrequency/sampleRate)) · carrier, which simplifies to (Ï/360°) · sampleRate.
FM Transmission
Now all that's left is determining the correct amount of phase change for each audio sample. At 1 MHz sample rate, the maximum we could do (with 180°, independent of carrier frequency) is ± 500 kHz, and we only need ± 12.5 kHz â so, the maximum required phase change is ± 4.5°.
We can also calculate that directly:
- deviation = (Ï/360°) · sampleRate
- â Ï = 360° · deviation / sampleRate
- â Ï = 360° · 12.5 kHz / 1 MHz
- â Ï = 4.5°
If we convert to radians (i.e., normalize 180° to Ï, which is how PySDR likes it), that's x = 2Ï Â· deviation / sampleRate.
At this point, there is one thing which I still do not understand: my code only works with x = Ï Â· deviation / sampleRate (note the missing factor of two). I suppose that I have simply mis-assumed the frequency deviation and should have used 6 kHz instead, but for now, I'm gonna leave this as-is. Let me know if you know more ^.^
So, what we finally have is:
phase_changes = samples * np.pi * fm_deviation / sdr_sample_rate
PySDR takes in absolute phase information, so we need to calculate the cumulative sum of this phase change array:
phase_integral = np.cumsum(phase_changes)
And with that, we can tell PySDR that we'd like to transmit a PM signal with a constant amplitude (of 1) and phases as determined by phase_integral:
fm_samples = np.exp(1j * phase_integral)
Boiler Plate
Now all that's left is scaling everything up from -1 ⊠1 to the 15-bit signed values that PlutoSDR expects, and then transmitting those.
fm_samples *= 2**14
sdr = adi.Pluto("ip:âŠ")
sdr.sample_rate = int(sdr_sample_rate)
sdr.tx_rf_bandwidth = int(sdr_sample_rate)
sdr.tx_lo = int(fm_carrier)
sdr.tx_hardwaregain_chan0 = -10
print("SDR is being configured, waiting 2 seconds before beginning transmission âŠ")
time.sleep(2)
for i in range(fm_samples.shape[0] // n_samples_per_tx):
sdr.tx(fm_samples[i * n_samples_per_tx : (i + 1) * n_samples_per_tx])
The full script (with some quality-of-life improvements) is available at plutosdr-playground/tx-fm.py. Next item on the todo list is probably using multiprocessing so that the transmission can already start as the input file is being processed.
Automatic Screen Rotation on a Chuwi Minibook X
I recently got myself a new laptop (yes, in 2026, of all times â but at 360âŹ, I'd say the price was quite acceptable): a Chuwi Minibook X. Performance-wise, it's nothing to write home about â it's slightly faster than the X270 I've been using since 2017, and I don't need any more than that. However, with a 10.5" Full HD touchscreen that supports 360° rotation, it's a really nifty netbook / tablet hybrid, and pretty much exactly the type of device that I've been looking for. Especially for hiking and related trips, I like having a laptop with me to pass the time on the train, but a 13.5" laptop was just too unwieldy for that.
When running the Minibook X with Gnome or similar environments, orientation-dependent screen rotation etc. should just happen automatically. In my case, I'm running i3, so I need to do that on my own. And, even if you don't want automatic rotation, you do need to make some changes at least once after booting: the netbook is using a tablet screen, and thus its native orientation is portrait mode.
Boot-Time Screen Orientation
Add the following parameter to the kernel command line (e.g., on Debian, by appending it to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub):
video=DSI-1:panel_orientation=right_side_up
Reading Out the Screen Angle
The netbook contains two identical accelerometers, one in the screen and one in the base. By default, Linux only exposes the one in the screen. There are ways around that, but in my case, a screen-only solution is sufficient.
So: /sys/bus/i2c/drivers/mxc4005/i2c-MDA6655:00/iio:device0/in_accel_?_raw contains raw readings in x, y, and z direction, depending on what you substitute for ?.
You could do some fancy trigonometry now, or you could just use the simplest heuristic that you can come up with.
I opted for the latter:
def get_accel(axis):
with open(
f"/sys/bus/i2c/drivers/mxc4005/i2c-MDA6655:00/iio:device0/in_accel_{axis}_raw",
"r",
) as f:
x = int(f.read())
return x
if __name__ == "__main__":
while True:
x = get_accel("x")
y = get_accel("y")
if x < -500 and mode != "up":
new_mode = "up"
# The screen is in normal laptop orientation
elif x > 500 and mode != "down":
new_mode = "down"
# The screen is in inverse laptop orientation ("tent mode")
if y < -500 and mode != "normal":
new_mode = "normal"
# The screen is in its native orientation: it has been rotated into portrait mode so that the hinge is on the left when looking at the screen
elif y > 500 and mode != "flip":
new_mode = "flip"
# The screen has been rotated into portrait mode so that the hinge is on the right when looking at the screen
# ...
time.sleep(1)
Changing Screen Orientation
Adjusting the screen orientation actually consists of two commands: one for output (xrandr, as usual), and one for touch input (xinput coordinate transformation matrix). Otherwise, touchscreen events will no longer map to the right display coordinates.
Laptop Configuration ("up")
xrandr --output DSI-1 --rotate rightxinput set-prop pointer:Goodix Capacitive TouchScreen --type=float Coordinate Transformation Matrix 0 1 0 -1 0 1 0 0 1
The second line sets the 3Ă3 coordinate transformation matrix to the following value:
0 1 0
-1 0 1
0 0 1
Tent Configuration ("down")
xrandr --output DSI-1 --rotate leftxinput set-prop pointer:Goodix Capacitive TouchScreen --type=float Coordinate Transformation Matrix 0 -1 1 1 0 0 0 0 1
The second line sets the 3Ă3 coordinate transformation matrix to the following value:
0 -1 1
1 0 0
0 0 1
Tablet Configuration 1 ("normal")
xrandr --output DSI-1 --rotate normalxinput set-prop pointer:Goodix Capacitive TouchScreen --type=float Coordinate Transformation Matrix 1 0 0 0 1 0 0 0 1
The second line sets the 3Ă3 coordinate transformation matrix to the following value:
1 0 0
0 1 0
0 0 1
Tablet Configuration 2 ("flip")
xrandr --output DSI-1 --rotate invertedxinput set-prop pointer:Goodix Capacitive TouchScreen --type=float Coordinate Transformation Matrix -1 0 1 0 -1 1 0 0 1
The second line sets the 3Ă3 coordinate transformation matrix to the following value:
-1 0 1
0 -1 1
0 0 1
Toggling Keyboard and Touchpad
Outside of laptop mode, I disable keyboard and touchpad so that I can actually use the device like a tablet.
Disabling Keyboard and Touchpad ("down", "normal", "flip")
xinput disable 'AT Translated Set 2 keyboard'xinput disable 'XXXX0000:05 0911:5288 Touchpad'
Enabling Keyboard and Touchpad ("normal")
xinput enable 'AT Translated Set 2 keyboard'xinput enable 'XXXX0000:05 0911:5288 Touchpad'
Setting the Wallpaper
After each rotation, you should set the wallpaper again to ensure that it is displayed correctly. How to do that depends on your setup â in my case, I'm using a simple wrapper around feh.
I have uploaded the full auto-rotate script at (sans custom wallpaper-setter) at chuwi-accel.py
Docker Multi-Platform Builds with a Remote Build Host
I finally got multi-platform / multi-arch Docker builds to work!
In principle, if you want to provide a single Docker image for both amd64 and arm64, all you need to do is run docker buildx build --platform linux/amd64,linux/arm64 âŠ.
However, in order for this to actually work, there are several hoops to jump through.
The following how-to is mostly reconstructed from short-term memory and shell history, so you may want to double-check what I'm writing with the documentation.
Docker Storage Format
First, if you're using Docker version 28 or earlier, you need to change the storage format to one that supports multi-platform containers.
In my case, merging the following content into /etc/docker/daemon.json was sufficient:
{
"features": {
"containerd-snapshotter": true
}
}
Not Recommended: binfmt / qemu
I first tried multi-platform builds by installing qemu-system-aarch64 and binfmt-support â in this case, Docker will use (slow!) software emulation for any selected, non-native platform.
However, I never got that to work â the non-native part would always fail at random places, and given its slow progress I was not very keen on debugging it.
Instead, I opted for a setup with two different, native build hosts: one for amd64 and one for arm64. In my case, I have an amd64 VM as the main build host, and an aarch64 / armv8 SoC as build host for arm64. I want to run all commands on the amd64 VM, and the arm64 host should be fully remote-controlled.
Adding Contexts and Builders
In order to make the amd64 VM aware of the arm64 build host, we need two aspects: a context and a builder.
docker context create raspi4 --docker 'host=ssh://user@host'
docker buildx create --use --name arm64_raspi4 --platform linux/arm64 raspi4
(adjust user and host according to your setup)
(Note: the second line may not be required if all you want / need is a single multi-platform builder â see below)
At this point, we can build either for amd64 or arm64, but not yet both at the same time.
If we tried to run docker buildx build --platform linux/amd64,linux/arm64 ⊠now, it would fall back to software emulation via qemu for one of the two platforms, depending on which of the two builders (default / arm64_raspi4) is currently selected.
Note: In principle, you can also adjust the arm64 host's systemd docker invocation to include -H tcp://0.0.0.0:port, and then use --docker 'host=tcp://ip:port' when creating the context.
However, that will give anyone on the local network docker (and, thus, root) access to the arm64 host.
An SSH connection is a much better choice.
Adding a Multi-Platform Builder
Luckily, Docker has a concept of builders that consist of multiple endppoints:
docker buildx create --use --name multiarch default
docker buildx create --append --name multiarch raspi4
As docker buildx ls shows, we now have a builder that supports two sets of platforms:
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
multiarch* docker-container
\_ multiarch0 \_ unix:///var/run/docker.sock running v0.29.0 linux/amd64, linux/amd64/v2, linux/386
\_ multiarch1 \_ raspi4 running v0.29.0 linux/arm64, linux/arm/v7, linux/arm/v6
And, as the asterisk indicates, it has been selected as builder for all subsequent docker commands. At this point, building works as intended. In my case, I'm using the following commandline to also tag and push the multi-platform image to docker hub:
docker buildx build --push --platform linux/amd64,linux/arm64 --tag derfnull/db-fakedisplay:${VERSION} --tag derfnull/db-fakedisplay:latest --build-arg=dbf_version=${VERSION} .