<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>Technical notes · Nob</title>
  <link>https://nuulm.com/notas</link>
  <atom:link href="https://nuulm.com/notas/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Technical notes by Nob: what I learned building IP Rotator, celltrace, jukz, AccessScope and the other tools, mistakes included</description>
  <language>en</language>
  <lastBuildDate>Thu, 01 Oct 2026 12:00:00 +0000</lastBuildDate>
  <image><url>https://nuulm.com/og/en/notas.png</url><title>Technical notes · Nob</title><link>https://nuulm.com/notas</link></image>
  <item>
    <title>The command that actually turns the radio off</title>
    <link>https://nuulm.com/notas/apagar-el-radio</link>
    <guid isPermaLink="true">https://nuulm.com/notas/apagar-el-radio</guid>
    <pubDate>Thu, 01 Oct 2026 12:00:00 +0000</pubDate>
    <description>Why airplane mode doesn't always change your IP, what the carrier's CGNAT really does, and how to rotate an Android phone's IP from a PC over ADB without root (cmd connectivity airplane-mode)</description>
    <category>Android</category><category>ADB</category><category>CGNAT</category>
    <enclosure url="https://nuulm.com/og/en/notas/apagar-el-radio.png" type="image/png" length="0"/>
    <content:encoded><![CDATA[<p class="rv">The question was a concrete one: can you change an Android phone's public IP from a PC alone, over ADB, without root? A lot of people take it for granted that airplane mode always gets you a new IP, and I wanted to check that with evidence</p>
<h2 class="rv" id="s1">What people believe</h2>
<p class="rv">The idea gets around a lot: if you want another IP, turn airplane mode on for a few seconds and turn it off again. And it often works, which is why people keep repeating it. The problem is the «always»: sometimes the IP comes back the same, and not always for the same reason</p>
<h2 class="rv" id="s2">What actually happens</h2>
<p class="rv">To understand it you have to separate two IPs that tend to get mixed up. When the phone uses mobile data, it opens a data session with the carrier's network (in LTE it's called a PDN connection), and the core of that network assigns it an IP. With many carriers that IP is private: the phone doesn't reach the internet with it, but through a CGNAT, a carrier-side NAT that shares a few public IPs among a huge number of users</p>
<figure class="dg glass rv"><div class="tb"><i></i><i></i><i></i><em>how it works</em></div><pre>phone
 <i>│</i> data session
 <i>▼</i>
tower <i>──►</i> carrier core
            <i>│</i> assigns a private IP (for example 10.x.x.x or 100.64.x.x)
            <i>▼</i>
          CGNAT
            <i>│</i> translates it to a shared public IP
            <i>▼</i>
          what the internet sees: <b>190.130.xxx.204</b></pre></figure>
<p class="rv">The <code>100.64.0.0/10</code> range exists precisely for this: RFC 6598 reserved it for the space between a carrier and its CGNAT. And RFC 6888 requires that, by default, a CGNAT keep the same public IP for the same internal IP while it's in use («paired pooling»), so a user's connections don't go out through different addresses</p>
<p class="rv">When airplane mode really powers the radio off, that session closes. When it comes back, the phone opens a new session and the carrier assigns it an IP again, usually a different one. But what the internet sees depends on which public address the CGNAT hands it: it may be a new one, or the same one as before</p>
<p class="rv">That's why the result isn't guaranteed. There are at least three reasons for the IP to come back the same:</p>
<ul class="rv"><li>The radio didn't really go off. If only a setting changes, the session stays open and the carrier has no reason to give you another IP. That's exactly what happened in my first attempt</li><li>The CGNAT gives you the same public IP again. The session is new, but the shared address you get can repeat</li><li>What's being measured isn't the mobile network. If Wi-Fi is still on, traffic goes out through the home internet, and that IP won't change no matter how many times you toggle airplane mode</li></ul>
<p class="idea rv"><small>idea</small>In short: airplane mode isn't what changes the IP. What can change it is the data session closing and a new one opening; airplane mode is just one way to trigger that, and it doesn't always manage to</p>
<h2 class="rv" id="s3">The first attempt</h2>
<p class="rv">IP Rotator's modes A and B used this:</p>
<figure class="cod glass rv" data-copiar="adb shell settings put global airplane_mode_on 1"><div class="tb"><i></i><i></i><i></i><em>terminal</em><button type="button" data-ok="copied">copy</button></div><pre><span class="pr">$ </span>adb shell settings put global airplane_mode_on 1</pre></figure>
<p class="rv">It looked like it worked, because the setting changed. But the radio logs (<code>logcat -b radio</code>) told a different story: the radio never let go of the tower. The command only flipped a stored value, so the carrier kept the same CGNAT session and the same IP</p>
<p class="rv">In December 2025 the verdict went into the v0.1.0 README: ADB couldn't do it without system permissions</p>
<h2 class="rv" id="s4">The right command</h2>
<p class="rv">Six months later, while I was building Goket (my network panel), Claude found another way:</p>
<figure class="cod glass rv" data-copiar="adb shell cmd connectivity airplane-mode enable
adb shell cmd connectivity airplane-mode disable"><div class="tb"><i></i><i></i><i></i><em>terminal</em><button type="button" data-ok="copied">copy</button></div><pre><span class="pr">$ </span>adb shell cmd connectivity airplane-mode enable
<span class="pr">$ </span>adb shell cmd connectivity airplane-mode disable</pre></figure>
<p class="rv">The difference is the path it takes. This command goes through ConnectivityService, the same path as the system toggle, and it really powers the radio off. It works without root since Android 12. On Android 16, the old AIRPLANE_MODE broadcast can't even be sent from ADB anymore: it throws SecurityException</p>
<h2 class="rv" id="s5">The details that mattered</h2>
<p class="rv">The command alone wasn't enough. It took three more things:</p>
<ul class="rv"><li>The phone's Wi-Fi is switched off during the test (<code>svc wifi disable</code>). Otherwise tethering goes out through the home internet and the measured IP isn't the carrier's</li><li>Under CGNAT a new IP isn't guaranteed on the first try, so it repeats short cycles: 7 s off, 12 s to settle, up to 5 times</li><li>The IP is measured through the tethering interface (<code>curl --interface</code>), so the PC never loses its own internet</li></ul>
<p class="rv">In the real test on September 30, 2026, the IP changed on the first try, in 20 s. It shipped as v0.2.0</p>
<h2 class="rv" id="s6">Other ideas, untested</h2>
<p class="rv">If what changes the IP is the data session closing and a new one opening, airplane mode shouldn't be the only way. Two ideas I'm writing down, not tested yet:</p>
<ul class="rv"><li><b>Turning only mobile data off and on.</b> It would close the data session without powering off the whole radio, and it might be faster than a full airplane-mode cycle</li><li><b>Switching the preferred network type.</b> Going from 5G or 4G down to 3G and back would make the phone register with the network again, and so open a new session</li></ul>
<p class="rv">If either one works, it would need to be checked the same way as airplane mode: measure the IP before and after, and look at the radio logs</p>
<h2 class="rv" id="s7">Limits</h2>
<ul class="rv"><li>What was measured comes from a single phone and a single carrier. Another carrier may assign IPs differently, or even give a fixed IP per line</li><li>How many cycles it takes to get a new IP depends on the carrier's CGNAT; the 5 attempts are a practical cap, not a number measured in general</li><li>The two ideas above are hypotheses: there's no data yet</li></ul>
<h2 class="rv" id="s8">What stuck with me</h2>
<p class="rv">December's verdict was true for that command: with it, it really couldn't be done. What was wrong was treating the question as closed when what had failed was the tool. And it was the radio logs, not intuition, that showed where the problem was</p>]]></content:encoded>
  </item>
  <item>
    <title>Saying «I don’t know» is a result too</title>
    <link>https://nuulm.com/notas/decir-no-se</link>
    <guid isPermaLink="true">https://nuulm.com/notas/decir-no-se</guid>
    <pubDate>Thu, 01 Oct 2026 12:00:00 +0000</pubDate>
    <description>How to infer which cell tower serves a phone without GPS (Cell ID, RSRP, OpenCellID) with a confidence level, and how to classify a Minecraft server reply with rules that vote and allow «ambiguous»</description>
    <category>design</category><category>uncertainty</category><category>cellular networks</category>
    <enclosure url="https://nuulm.com/og/en/notas/decir-no-se.png" type="image/png" length="0"/>
    <content:encoded><![CDATA[<p class="rv">Some tools always give you an answer, even when they don't have one. In two of my projects I went the other way: if the data isn't enough, the result says so</p>
<h2 class="rv" id="s1">What a phone knows about the network</h2>
<p class="rv">A connected phone knows more than it shows: the country and carrier (MCC and MNC), the area (LAC in 3G, TAC in LTE and 5G), the cell serving it (Cell ID) and the signal quality. In LTE, the reference signal power is measured as RSRP and its quality as RSRQ (both defined in the 3GPP TS 36.214 specification); RSSNR is the signal-to-noise ratio. CellTraceLogger records all of it, on 5G NR, LTE and 3G</p>
<figure class="dg glass rv"><div class="tb"><i></i><i></i><i></i><em>how it works</em></div><pre>observation (one NDJSON line)
 <i>│</i> MCC · MNC · TAC · Cell ID · RSRP · RSRQ · RSSNR
 <i>▼</i>
celltrace <i>◄──</i> OpenCellID: Cell ID → approximate position
 <i>│</i> does one cell dominate over time? is the signal stable?
 <i>▼</i>
<b>most likely tower · confidence</b></pre></figure>
<p class="rv">Mind what a Cell ID identifies: the cell, usually one sector of an antenna, not an exact point. And a cell's position in OpenCellID comes from crowdsourced measurements, so it's approximate too. That's why celltrace infers infrastructure (which tower or sector), not where a person is</p>
<h2 class="rv" id="s2">celltrace: low, medium or high confidence</h2>
<p class="rv">Every inference comes with a confidence level, based on how stable the signal was and whether one cell dominates over time. If cells overlap or the network is unstable, it says so instead of picking one. The project page has a simulation: when the phone sits between two towers, the observations split and confidence goes down</p>
<h2 class="rv" id="s3">AccessScope: rules that vote</h2>
<p class="rv">AccessScope joins a Minecraft server like a player would (with Mineflayer) and classifies what the server replies: the kick message, the MOTD, the version. The classification uses hand-written rules, and the rules vote. If two point to different things, the result is «ambiguous» and none is picked:</p>
<figure class="cod glass rv" data-copiar="message: &quot;This server is whitelisted. Banned players cannot join.&quot;

  whitelist  ✓  → whitelist
  ban        ✓  → ban
  ─────────────────────────────
  result        → ambiguous"><div class="tb"><i></i><i></i><i></i><em>terminal</em><button type="button" data-ok="copied">copy</button></div><pre>message: "This server is whitelisted. Banned players cannot join."

  whitelist  ✓  → whitelist
  ban        ✓  → ban
  ─────────────────────────────
  result        → ambiguous</pre></figure>
<p class="rv">It doesn't stretch the evidence either: an account problem isn't reported as a ban, because it's no evidence of one</p>
<h2 class="rv" id="s4">The underlying idea: abstaining</h2>
<p class="rv">This has a name in pattern recognition: a classifier with a reject option. C. K. Chow formalized it in 1970: if a system can decline to decide in doubtful cases, it makes fewer errors on the ones it does decide, at the cost of answering less often. It's a trade-off between precision and coverage</p>
<figure class="dg glass rv"><div class="tb"><i></i><i></i><i></i><em>how it works</em></div><pre>always answer        → high coverage, more errors stated with confidence
abstain when unsure  → lower coverage, fewer errors in what gets answered
                       and the doubtful cases stay <b>in plain sight</b></pre></figure>
<p class="rv">Both tools pick the second side. And in AccessScope the aggregation rule is conservative on purpose: the majority doesn't win, there has to be no contradiction</p>
<p class="idea rv"><small>idea</small>A wrong answer stated with confidence does more harm than an «I don't know». If the tool admits doubt, whoever uses it knows when to trust it and when to take a closer look</p>
<h2 class="rv" id="s5">Limits</h2>
<ul class="rv"><li>AccessScope's rules only cover messages I've already seen; a new one stays unclassified until its rule is written. That happened with a ban message in Spanish it didn't recognize at first</li><li>celltrace's quality depends on how many cells in the area are in OpenCellID; where data is scarce, confidence drops even with a good signal</li><li>I didn't measure how much precision goes up by abstaining: the argument is a design one, not an experimental result</li></ul>]]></content:encoded>
  </item>
  <item>
    <title>A world that lives in exactly one place</title>
    <link>https://nuulm.com/notas/un-solo-lugar</link>
    <guid isPermaLink="true">https://nuulm.com/notas/un-solo-lugar</guid>
    <pubDate>Thu, 01 Oct 2026 12:00:00 +0000</pubDate>
    <description>How a Minecraft mod shares a world among friends without a running server: one host at a time, generation tokens against split-brain, UPnP, STUN and relay to get through NAT and CGNAT, and a world handed over with JGit</description>
    <category>Minecraft</category><category>P2P</category><category>distributed systems</category>
    <enclosure url="https://nuulm.com/og/en/notas/un-solo-lugar.png" type="image/png" length="0"/>
    <content:encoded><![CDATA[<p class="rv">jukz is a Minecraft mod for playing in the same world with friends without keeping a server running. The world lives on the PC of whoever is playing. The rule behind the whole design is simple: there are no copies to sync, the world lives in exactly one place at a time</p>
<h2 class="rv" id="s1">The underlying problem: two leaders</h2>
<p class="rv">In distributed systems this is known as split-brain: two nodes believe at the same time that they're the leader, and each one accepts changes on its own. When they meet again there isn't one correct version, there are two. In a Minecraft world that means lost builds or inventories</p>
<p class="rv">jukz avoids it in two ways: by asking before opening, and by using a generation number to settle the case where two people open the world at once</p>
<h2 class="rv" id="s2">Opening a world is asking</h2>
<p class="rv">Every world has a permanent UUID and a short share code. When you open it, jukz asks whether someone already has it open: on the same network, over multicast; from different homes, through a rendezvous server of my own written in Rust (Axum). If someone has it, you join that person's world live. If not, it opens on your PC and you're announced as the host</p>
<p class="rv">At first I had planned a DHT for finding each other, but it got dropped: the rendezvous server was simpler and more reliable</p>
<h2 class="rv" id="s3">Never two hosts</h2>
<p class="rv">Every announcement carries a token with a generation number. If two people open the same world at the same time, the newest generation wins and the other one finds out: they can keep their local copy or join the live game as a guest</p>
<figure class="cod glass rv" data-copiar="Nob opens the world        → host, generation 7
Kai opens it at once       → host, generation 8
                             8 wins
Nob finds out              → keeps the local copy
                             or joins as a guest"><div class="tb"><i></i><i></i><i></i><em>terminal</em><button type="button" data-ok="copied">copy</button></div><pre>Nob opens the world        → host, generation 7
Kai opens it at once       → host, generation 8
                             8 wins
Nob finds out              → keeps the local copy
                             or joins as a guest</pre></figure>
<p class="rv">It's the same idea Martin Kleppmann describes as a fencing token: a number that only goes up and travels with whoever holds the permission. Anything that arrives with an old number is recognized as old, even if its owner still thinks it's in charge</p>
<h2 class="rv" id="s4">Getting through each home's NAT</h2>
<p class="rv">For a friend to join the world, their PC has to reach the host's, and there's almost always a router doing NAT in between. jukz combines three pieces:</p>
<figure class="dg glass rv"><div class="tb"><i></i><i></i><i></i><em>how it works</em></div><pre>UPnP <i>──►</i> the host asks its router to open a port
STUN <i>──►</i> each PC finds out what its public address is
 <i>│</i> if the port can't be opened (behind CGNAT, for example)
 <i>▼</i>
<b>WebSocket relay</b> on the rendezvous server: joins the two outgoing connections</pre></figure>
<p class="rv">The last step is the same idea TURN standardizes (RFC 8656): if two machines can't connect directly, both connect outward to a third party that passes the data along. It costs a bit more latency, but it works even when neither can accept incoming connections</p>
<h2 class="rv" id="s5">Handing the world over</h2>
<p class="rv">When the host quits with guests connected, it saves, snapshots the world with JGit and hands it to a guest, who applies it and carries on as the host. If it quits with nobody around, the snapshot goes up to the cloud (R2) and the next person to open the world downloads it and picks up from there</p>
<p class="rv">This didn't work the first time. On June 11 the world fell out of sync: handing it to the next host was losing changes. The fix was a WebSocket relay on the rendezvous server, which carries the world copy</p>
<h2 class="rv" id="s6">Testing it without opening the game</h2>
<p class="rv">The hard logic (picking the host, the tokens, the protocol) lives in a pure Kotlin module, with no Minecraft in it, and 67 tests that run in seconds. Handing the world over was tested inside the game, including a round trip A → B → A</p>
<p class="idea rv"><small>idea</small>Keeping the hard logic apart from the game made it possible to test the odd cases (two opens at once, a host that leaves) in seconds, without launching Minecraft every time</p>
<h2 class="rv" id="s7">Limits</h2>
<ul class="rv"><li>It's meant for small groups of friends: one host at a time means the world depends on the PC and connection of whoever is hosting at that moment</li><li>The automated tests cover the logic; the real network was tested by hand, not with many players or under hard network conditions at scale</li><li>The relay adds one more hop: it's the last resort, not the ideal</li></ul>]]></content:encoded>
  </item>
</channel>
</rss>
