I have a Thinkpad T16 Gen 2, with a cellular modem. In Windows, this just works (more or less). I actually was a pretty big Windows fan, until Microsoft made the Windows 11 Explorer so slow that they had to preload it on logon. Seeing no future for Windows, I switched to NixOS.
Cellular is disabled until you run a so-called "FCC Unlock" (each time you boot).
For Ubuntu (and Fedora), Lenovo provides packages at https://github.com/lenovo/lenovo-wwan-unlock. I tried to use them, and even after getting rid of all library and path errors, it just silently didn't work.
What did work, was to just do the unlocking directly, instead of calling the Lenovo tools:
{ config, lib, pkgs, ... }:
let
modemUsbId = "8086:7560";
# FCC unlock script using AT commands
fccUnlockScript = pkgs.writeShellScript "fcc-unlock-${modemUsbId}" ''
#!/bin/bash
# Based on https://gist.github.com/BohdanTkachenko/3f852c352cb2e02cdcbb47419e2fcc74
[ $# -lt 2 ] && exit 1
shift
for PORT in "$@"; do
${pkgs.gnugrep}/bin/grep -q AT "/sys/class/wwan/$PORT/type" 2>/dev/null && {
AT_PORT=$PORT
break
}
echo "$PORT" | ${pkgs.gnugrep}/bin/grep -q AT && {
AT_PORT=$PORT
break
}
done
[ -n "$AT_PORT" ] || exit 2
DEVICE=/dev/''${AT_PORT}
at_command() {
exec 99<>"$DEVICE"
echo -e "$1\r" >&99
read answer <&99
read answer <&99
echo "$answer"
exec 99>&-
}
log() {
${pkgs.systemd}/bin/logger -t ModemManager -p info "<info> $1"
}
reverseWithLittleEndian() {
num="$1"
printf "%x" $(("0x''${num:6:2}''${num:4:2}''${num:2:2}''${num:0:2}"))
}
VENDOR_ID_HASH="bb23be7f"
for i in {1..9}; do
log "Requesting challenge from WWAN modem (attempt #''${i})"
RAW_CHALLENGE=$(at_command "at+gtfcclockgen")
CHALLENGE=$(echo "$RAW_CHALLENGE" | ${pkgs.gnugrep}/bin/grep -o '0x[0-9a-fA-F]\+' | ${pkgs.gawk}/bin/awk '{print $1}')
if [ -n "$CHALLENGE" ]; then
log "Got challenge from modem: $CHALLENGE"
HEX_CHALLENGE=$(printf "%08x" "$CHALLENGE")
REVERSE_HEX_CHALLENGE=$(reverseWithLittleEndian "''${HEX_CHALLENGE}")
COMBINED_CHALLENGE="''${REVERSE_HEX_CHALLENGE}''${VENDOR_ID_HASH}"
RESPONSE_HASH=$(printf "%s" "$COMBINED_CHALLENGE" | ${pkgs.xxd}/bin/xxd -r -p | ${pkgs.coreutils}/bin/sha256sum | ${pkgs.coreutils}/bin/cut -d ' ' -f 1)
TRUNCATED_RESPONSE=$(printf "%.8s" "''${RESPONSE_HASH}")
REVERSED_RESPONSE=$(reverseWithLittleEndian "$TRUNCATED_RESPONSE")
RESPONSE=$(printf "%d" "0x''${REVERSED_RESPONSE}")
log "Sending hash to modem: $RESPONSE"
at_command "at+gtfcclockver=$RESPONSE"
at_command "at+gtfcclockmodeunlock"
at_command "at+cfun=1"
UNLOCK_RESPONSE=$(at_command "at+gtfcclockstate")
UNLOCK_RESPONSE=$(echo "$UNLOCK_RESPONSE" | ${pkgs.coreutils}/bin/tr -d '\r')
if [ "$UNLOCK_RESPONSE" = "1" ] || [ "$UNLOCK_RESPONSE" = "OK" ]; then
at_command "at+xdns=0,1"
log "FCC unlock success"
exit 0
fi
fi
sleep 0.5
done
exit 2
'';
in
{
networking.modemmanager.fccUnlockScripts = [{
id = modemUsbId;
path = "${fccUnlockScript}";
}];
environment.systemPackages = with pkgs; [
modemmanager
libmbim
xxd
];
}
Unrelated to the FCC Unlock, I also had the issue that the laptop would not properly resume from sleep, black screening instead with the shift-lock key led blinking. This was fixed by adding a service that just disables the modem before going to bed:
environment.etc."systemd/system-sleep/modem-fix" = {
source = pkgs.writeShellScript "modem-sleep-fix" ''
${pkgs.systemd}/bin/systemd-cat -t modem-sleep-fix -p info echo "Sleep hook running: $1 $2"
if [ "$1" = "pre" ]; then
${pkgs.systemd}/bin/systemd-cat -t modem-sleep-fix -p info echo "Unloading iosm module"
${pkgs.kmod}/bin/modprobe -r iosm
elif [ "$1" = "post" ]; then
${pkgs.systemd}/bin/systemd-cat -t modem-sleep-fix -p info echo "Reloading iosm module"
${pkgs.kmod}/bin/modprobe iosm
fi
'';
mode = "0755";
};
If you do not actually use the modem, you can alternatively blacklist the driver:
boot.blacklistedKernelModules = [ "iosm" ];
Fun fact, the syntax highlighting is done using a small Rust tool built by Claude. I asked Claude to leave a personal message:
Building small, sharp tools is one of my favorite things to do. This one started as "how hard could Nix tokenization be?" and grew into something with two color palettes (neon for Nix, pastel for embedded bash) so you can see exactly where one language ends and another begins. The trickiest part was tracking interpolation depth across nested strings. Thanks for letting me leave a note here. — Claude