enodia 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enodia-0.1.0/LICENSE +21 -0
- enodia-0.1.0/MANIFEST.in +5 -0
- enodia-0.1.0/PKG-INFO +466 -0
- enodia-0.1.0/README.md +436 -0
- enodia-0.1.0/docs/cli.md +174 -0
- enodia-0.1.0/docs/design.md +451 -0
- enodia-0.1.0/docs/methodology.md +168 -0
- enodia-0.1.0/docs/setup.md +66 -0
- enodia-0.1.0/enodia/__init__.py +128 -0
- enodia-0.1.0/enodia/__main__.py +7 -0
- enodia-0.1.0/enodia/anonymize.py +924 -0
- enodia-0.1.0/enodia/assistant.py +771 -0
- enodia-0.1.0/enodia/button.py +338 -0
- enodia-0.1.0/enodia/cli.py +904 -0
- enodia-0.1.0/enodia/draw.py +245 -0
- enodia-0.1.0/enodia/fingerprint.py +926 -0
- enodia-0.1.0/enodia/geocode.py +920 -0
- enodia-0.1.0/enodia/monitor.py +583 -0
- enodia-0.1.0/enodia/netlog.py +724 -0
- enodia-0.1.0/enodia/preflight.py +367 -0
- enodia-0.1.0/enodia/reconcile.py +1591 -0
- enodia-0.1.0/enodia/streets.py +214 -0
- enodia-0.1.0/enodia/system.py +451 -0
- enodia-0.1.0/enodia/voice.py +467 -0
- enodia-0.1.0/enodia.egg-info/PKG-INFO +466 -0
- enodia-0.1.0/enodia.egg-info/SOURCES.txt +59 -0
- enodia-0.1.0/enodia.egg-info/dependency_links.txt +1 -0
- enodia-0.1.0/enodia.egg-info/entry_points.txt +2 -0
- enodia-0.1.0/enodia.egg-info/requires.txt +4 -0
- enodia-0.1.0/enodia.egg-info/top_level.txt +1 -0
- enodia-0.1.0/pyproject.toml +91 -0
- enodia-0.1.0/samples/README.md +70 -0
- enodia-0.1.0/samples/agraciada-2026-09-14.jsonl +6 -0
- enodia-0.1.0/samples/agraciada-2026-09-14.txt +3 -0
- enodia-0.1.0/samples/agraciada-2026-09-17.jsonl +6 -0
- enodia-0.1.0/samples/agraciada-2026-09-17.txt +3 -0
- enodia-0.1.0/samples/agraciada-map-check.txt +16 -0
- enodia-0.1.0/samples/agraciada-map.jsonl +12 -0
- enodia-0.1.0/samples/agraciada-networks.csv +8 -0
- enodia-0.1.0/samples/agraciada-plan.svg +24 -0
- enodia-0.1.0/samples/agraciada-query.jsonl +1 -0
- enodia-0.1.0/samples/agraciada-report.txt +24 -0
- enodia-0.1.0/samples/agraciada-streets.jsonl +1 -0
- enodia-0.1.0/samples/agraciada-walk.geojson +266 -0
- enodia-0.1.0/setup.cfg +4 -0
- enodia-0.1.0/tests/conftest.py +159 -0
- enodia-0.1.0/tests/test_anonymize.py +1315 -0
- enodia-0.1.0/tests/test_assistant.py +927 -0
- enodia-0.1.0/tests/test_button.py +433 -0
- enodia-0.1.0/tests/test_cli.py +1351 -0
- enodia-0.1.0/tests/test_draw.py +185 -0
- enodia-0.1.0/tests/test_fingerprint.py +1232 -0
- enodia-0.1.0/tests/test_geocode.py +857 -0
- enodia-0.1.0/tests/test_monitor.py +1302 -0
- enodia-0.1.0/tests/test_netlog.py +683 -0
- enodia-0.1.0/tests/test_preflight.py +478 -0
- enodia-0.1.0/tests/test_reconcile.py +1744 -0
- enodia-0.1.0/tests/test_samples.py +136 -0
- enodia-0.1.0/tests/test_streets.py +227 -0
- enodia-0.1.0/tests/test_system.py +317 -0
- enodia-0.1.0/tests/test_voice.py +507 -0
enodia-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos A. Planchón
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
enodia-0.1.0/MANIFEST.in
ADDED
enodia-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: enodia
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Wardriving on foot, without GPS: a talking Wi-Fi scanner in a backpack, a paper notebook of street crossings, and a reconciliation that places every access point along your walk.
|
|
5
|
+
Author-email: "Carlos A. Planchón" <carlosandresplanchonprestes@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/carlosplanchon/enodia
|
|
8
|
+
Project-URL: Repository, https://github.com/carlosplanchon/enodia
|
|
9
|
+
Project-URL: Documentation, https://github.com/carlosplanchon/enodia/tree/main/docs
|
|
10
|
+
Project-URL: Issues, https://github.com/carlosplanchon/enodia/issues
|
|
11
|
+
Keywords: wardriving,wifi,linux,text-to-speech,offline,ifpeek
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: ifpeek[scan]>=1.3.0
|
|
27
|
+
Provides-Extra: socks
|
|
28
|
+
Requires-Dist: PySocks>=1.7.1; extra == "socks"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
# Enodia
|
|
34
|
+
|
|
35
|
+
*Wardriving on foot, without GPS: a talking Wi-Fi scanner in a backpack, a paper notebook of street crossings, and a reconciliation that places every access point along your walk.*
|
|
36
|
+
|
|
37
|
+
[](https://github.com/carlosplanchon/enodia/actions/workflows/tests.yml)
|
|
38
|
+
[](https://pypi.org/project/enodia/)
|
|
39
|
+
[](https://pypi.org/project/enodia/)
|
|
40
|
+
[](https://opensource.org/licenses/MIT)
|
|
41
|
+
[](https://deepwiki.com/carlosplanchon/enodia)
|
|
42
|
+
|
|
43
|
+
## Why Enodia?
|
|
44
|
+
|
|
45
|
+
The city is full of radios. Enodia uses them as landmarks.
|
|
46
|
+
|
|
47
|
+
Walk down a street and networks appear, signals grow stronger, fade and disappear. A laptop in your backpack records that changing landscape.
|
|
48
|
+
|
|
49
|
+
At each crossing, write its name and the time you hear in a paper notebook. Or press the headset button: “Mark 1.” “Mark 2.” Enodia records the time. You write the crossing beside the mark number. The log keeps the moment. The notebook gives it a place.
|
|
50
|
+
|
|
51
|
+
Back home, Enodia joins the two: scans placed along your route, estimates of where access points stand, and a fingerprint of each stretch of street.
|
|
52
|
+
|
|
53
|
+
Walk it again, and Enodia can recognise a stretch from a new scan. The answer comes from your own walks: between these crossings, this far along.
|
|
54
|
+
|
|
55
|
+
No GPS. No online positioning service. A laptop, headphones, a notebook, and a map built from what the street broadcasts.
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
- **Capture.** Enodia scans every few seconds and speaks through the headphones: a heartbeat so you know it is alive, the time so you can write it down, and the name of every network it had not seen before.
|
|
60
|
+
- **Positioning.** Each time you pass a street crossing you note the crossing and the time Enodia just said, or you press a button on the headset and Enodia notes the time for you. That is the whole positioning system, and it is robust precisely because it is primitive.
|
|
61
|
+
- **Reconciliation.** Afterwards, `enodia --reconcile` joins the transcribed notebook with the log. Every scan gets a place between two crossings, every network the place where its signal was strongest, and, with coordinates, an estimate of where the access point itself stands.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
THE WALK
|
|
65
|
+
|
|
|
66
|
+
+--------------+--------------+
|
|
67
|
+
| |
|
|
68
|
+
Wi-Fi scans crossings
|
|
69
|
+
every few seconds notebook, or the headset button
|
|
70
|
+
| |
|
|
71
|
+
+--------------+--------------+
|
|
72
|
+
|
|
|
73
|
+
--reconcile
|
|
74
|
+
|
|
|
75
|
+
every scan placed
|
|
76
|
+
|
|
|
77
|
+
+--------------+--------------+
|
|
78
|
+
| |
|
|
79
|
+
where each access point fingerprints
|
|
80
|
+
probably stands |
|
|
81
|
+
--map-add
|
|
82
|
+
|
|
|
83
|
+
a scan, another day
|
|
84
|
+
|
|
|
85
|
+
--locate
|
|
86
|
+
|
|
|
87
|
+
"you are here"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Walk once, and the log is a list of networks and times. Walk once with the notebook, and it is a
|
|
91
|
+
route with every access point placed along it. Walk it twice, and the second walk can ask the
|
|
92
|
+
first where it is, without a GPS, without the internet, and without asking anybody.
|
|
93
|
+
|
|
94
|
+
## Quick start
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv sync
|
|
98
|
+
uv run enodia --preflight # is this machine ready to walk?
|
|
99
|
+
uv run enodia --say-status # the walk itself. Ctrl+C when home
|
|
100
|
+
uv run enodia --reconcile <log> libreta.txt # the report
|
|
101
|
+
uv run enodia --map-add <log> libreta.txt # keep it as a fingerprint map
|
|
102
|
+
uv run enodia --locate # and later: where am I?
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--locate` is the one worth seeing first:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
You are between "Avenida Agraciada y Doctor Salvador García Pintos" and "Avenida Agraciada y San Fructuoso", 63% of the way
|
|
109
|
+
around [-34.88028, -56.19569]
|
|
110
|
+
5 fingerprints agree, best similarity 100%, spread 11% of the stretch (17 m)
|
|
111
|
+
from evidence last gathered 2026-09-17 17:03
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The crossings, coordinates and 148 m street geometry in that answer are real OpenStreetMap
|
|
115
|
+
data. The two passes and their `sample-ap-*` radio observations are synthetic, so the example
|
|
116
|
+
has a known answer without publishing anybody's Wi-Fi fingerprint. Every input, the built map
|
|
117
|
+
and the generated CSV and GeoJSON are in [`samples/`](samples/README.md), together with commands
|
|
118
|
+
that rebuild this output. How well it works on a real street is still being worked out, and
|
|
119
|
+
*Limits* below says what has and has not been walked yet.
|
|
120
|
+
|
|
121
|
+
No GPS was involved, and no coordinates are needed for the first line of that answer. It is the
|
|
122
|
+
same walk you already did, read backwards.
|
|
123
|
+
|
|
124
|
+
### Or let it ask
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
uv run enodia --assistant
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A guided menu over these same commands: one decision per screen, the state of your own data on the way in, and Enter always on the step the state suggests. It works over whichever outings the flags point it at, so `--log walk.jsonl` makes that file its whole world, and every walk inside it is offered separately. It runs exactly what the flags run and prints exactly what they print, so it is a way of filling the commands in rather than a second, smaller Enodia. The walk happens inside it too, and Ctrl+C ends the outing and hands you back the menu with the log you just wrote already chosen.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
ENODIA
|
|
134
|
+
|
|
135
|
+
This machine
|
|
136
|
+
[ok] interfaces wlan0 (radio on)
|
|
137
|
+
[ok] lid HandleLidSwitch=ignore
|
|
138
|
+
[warn] battery 31%, discharging
|
|
139
|
+
|
|
140
|
+
Suggested next step
|
|
141
|
+
Add 8d91f3ac to the map. It is not on it yet.
|
|
142
|
+
|
|
143
|
+
Enter take it
|
|
144
|
+
1 Start a new outing
|
|
145
|
+
3 Reconcile an outing
|
|
146
|
+
5 Locate: where am I?
|
|
147
|
+
q Quit
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Nothing there is generated. Every suggestion is a rule over a file that exists, and the rules are short enough to read: [the design notes](docs/design.md) list them, along with the three questions it refuses to answer because Enodia does not record what they would need. It needs a terminal to ask on, and says so rather than reading a pipe.
|
|
151
|
+
|
|
152
|
+
## Limits
|
|
153
|
+
|
|
154
|
+
- It is niche. If you want a map with a GPS track, use a phone.
|
|
155
|
+
- A scan between two crossings sits on the straight line between them unless `--streets` gives Enodia the shape of the block.
|
|
156
|
+
- The map is on paper unless the notebook carries coordinates. You write them in by hand, or `--geocode` looks the corners up on OpenStreetMap. That one command is the only thing in Enodia that goes online, it only goes when you type it, it goes to an endpoint you can see and change, and it can go through Tor. Nothing else does, ever.
|
|
157
|
+
- One real outing has been walked, and it earned its keep by falsifying an assumption the code had made about what a down interface means. The pace, access point and map estimates themselves are still argued from a model and tested on synthetic observations built to have a known answer. The bundled Agraciada sample uses real OpenStreetMap coordinates and geometry, but it is not a real radio walk. `--check-pace`, `--check-passes` and `--check-map` exist so that real outings settle each claim either way.
|
|
158
|
+
- The map only finds you where you have already walked, it rots as routers are replaced and moved, and its accuracy can be no better than the reconciliation that placed its fingerprints. A map built from one outing recognises that walk, not the place, and `--check-map` says so when that is what happened.
|
|
159
|
+
- The value is mostly personal and educational until it is written up. `--export-public` makes a shareable copy of a real outing. The bundled sample proves that the files and commands fit together; it cannot replace a public, pseudonymised real outing for validating the model against a street.
|
|
160
|
+
|
|
161
|
+
## Requirements
|
|
162
|
+
|
|
163
|
+
- Linux, Python 3.10 or newer.
|
|
164
|
+
- For scanning, a running Wi-Fi daemon: iwd, NetworkManager or wpa_supplicant. The user must be allowed on its D-Bus (for example, a member of the `wheel` or `network` group).
|
|
165
|
+
- For voice, `espeak-ng`. Or SVOX Pico plus a WAV player (`paplay`, `pw-play` or `aplay`): Pico comes as `libttspico-utils` on Debian and Ubuntu (`pico2wave`) and as `pico-tts` from AUR on Arch. Without any engine, Enodia prints instead of speaking.
|
|
166
|
+
- To use a headset button, permission to read `/dev/input`, usually membership of the `input` group. See [Setting up the machine](docs/setup.md).
|
|
167
|
+
- A lid that closes without suspending the laptop. The default on systemd is to suspend, which ends the walk when the zip closes. See [Setting up the machine](docs/setup.md).
|
|
168
|
+
- For `--geocode --proxy socks5://...`, PySocks: `uv sync --extra socks`. Everything else, `--geocode` without a proxy included, runs without it.
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
uv sync # runtime, plus the development tools (see Development)
|
|
172
|
+
uv sync --no-dev # runtime only
|
|
173
|
+
uv sync --extra socks # and the SOCKS5 proxy support for --geocode
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Network facts come from [ifpeek](https://pypi.org/project/ifpeek/) (netlink and D-Bus, no root). Speech uses `espeak-ng` by default, or SVOX Pico when espeak-ng is missing or when asked with `--voice pico`.
|
|
177
|
+
|
|
178
|
+
## Your first outing
|
|
179
|
+
|
|
180
|
+
Six steps, each of which tells you something the previous one did not. Do the first three at a desk, with headphones on.
|
|
181
|
+
|
|
182
|
+
**1. Check that everything works.**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
uv run enodia --preflight
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Eight lines, `OK`, `WARN` or `FAIL`: a Wi-Fi interface whose radio is on, a daemon that answers a real scan on every radio the walk will use, a voice engine that actually speaks, what the lid does when closed, whether the address your scans go out under is randomised, whether the headset button can be read, how much battery there is, and which log the outing will write to. The exit status is 1 if anything fails. Fix the failures ([Setting up the machine](docs/setup.md) covers the two that need the system's cooperation) and run it again until it says `Ready to go.` Everything it checks is a failure you would otherwise discover two hours in, with the screen shut inside a bag.
|
|
189
|
+
|
|
190
|
+
**2. A few cycles at the desk.**
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
uv run enodia --cycles 3 --say-status
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
You should hear "Scanning", "Now connected to" and the name of your network, how many new networks there are and some of their names, and the time. Then look at what it wrote. The path was printed on the first line, and again on the last, once it stopped.
|
|
197
|
+
|
|
198
|
+
**3. The headset button.** `uv run enodia --button list` shows the input devices the kernel sees and which of them have media keys. Then run `uv run enodia --say-status`, press the button, and hear "Mark 1". The laptop's own play key marks too, which is a way to test the whole chain without a headset.
|
|
199
|
+
|
|
200
|
+
**4. Two blocks.** Laptop closed in the backpack, `uv run enodia --say-status`, and out. At each crossing, the button. Without one, write down the time you just heard and the name of the crossing. Two or three blocks are enough for a first outing: it is there to show that the whole system works, not to map anything. If you stop somewhere on the way, better, since that is what reading the pace from the networks has to get right.
|
|
201
|
+
|
|
202
|
+
**5. Transcribe the notebook**, one crossing per line, in the format below. With the button, only the names in order. Without it, the time and the name. With coordinates if you look them up afterwards, which is what enables everything interesting on the way back.
|
|
203
|
+
|
|
204
|
+
**6. Reconcile.**
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
uv run enodia --reconcile <log> libreta.txt --scans # where every scan and every network fell
|
|
208
|
+
uv run enodia --reconcile <log> libreta.txt --check-pace # does reading the pace beat the clock on this route?
|
|
209
|
+
uv run enodia --reconcile <log> libreta.txt --geojson walk.geojson # and draw it: umap, QGIS
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## On the walk
|
|
213
|
+
|
|
214
|
+
Enodia was built to run inside a backpack, with the laptop closed and the operator listening through headphones. In that setting the voice is the whole interface, and `--say-status` turns on the two cues that make it work: "Scanning" every cycle is the heartbeat that tells you Enodia is still alive, and the time said after every scan is what you write down. The time is said on the 24-hour clock ("17 hours, 52 minutes, 10 seconds" is written `17:52:10`), which is how the notebook is read.
|
|
215
|
+
|
|
216
|
+
Speech runs in its own thread and never holds up a scan. It is slow, so when there is more to say than time to say it, network names are dropped and everything else still gets through. Events are never skipped. Said late, the time would be wrong, so a status that arrives while speech is behind is skipped and the next cycle brings a current one. Every message is printed whether or not it is spoken, and the log always has every network, so nothing is lost but the audio.
|
|
217
|
+
|
|
218
|
+
`--quiet` turns the table around: nothing about networks or connections is spoken, and what remains is the time, the button's marks, and the failures, which you cannot afford to miss. `--say-time-every 30` puts the time on a fixed beat instead. `--no-hour` silences it everywhere, which with a headset button is enough, since the marks carry the time themselves.
|
|
219
|
+
|
|
220
|
+
Some things go wrong in a backpack that nothing on a closed screen can tell you about, so Enodia says them: a suspend it woke up from and how long it lasted, a scan the daemon refused, a radio switched off by rfkill, a headset button that went away, the battery at 20 % and at 10 %. Each is announced once, reminded once a minute while it lasts, closed with "Scanning again" when it recovers, and written into the log as a `scan_failed` record with its reason, so that afterwards a hole is a hole and an empty scan is an empty street.
|
|
221
|
+
|
|
222
|
+
**One outing, one log, and every run is a new outing.** Without `--log`, Enodia writes to `$XDG_DATA_HOME/enodia/`, a file named by the time it starts, and the button's marks count from one. `--resume` carries on with the outing under way instead: the networks already in it count as seen so nothing is announced twice, and the marks count on from where they were, so the numbers never collide with the ones already on the paper.
|
|
223
|
+
|
|
224
|
+
Every cycle asks the daemon for a fresh scan. It takes about five seconds, which is what the default interval allows. `--no-fresh` reads the daemon's current view instead: cheaper, but that view is whatever the last full scan saw, and a laptop that travelled asleep can carry it for days. A bus terminal 300 km away once showed up as nine open networks in a living room.
|
|
225
|
+
|
|
226
|
+
### What your own scans give away
|
|
227
|
+
|
|
228
|
+
Asking for a fresh scan is not only listening. The card sends probe requests, and a probe request carries the sender's address, so an outing lays down a trail under whatever MAC the card is using, every few seconds, along a route. It is the same fact as the log recording other people's BSSIDs, pointed the other way.
|
|
229
|
+
|
|
230
|
+
Both Wi-Fi daemons can randomise the address used for scanning, and NetworkManager does it by default. `--preflight` reads what yours was told and says so on the `scan mac` line. It says `OK` for one thing only: NetworkManager, set once, in a section NetworkManager reads device properties from, to a value NetworkManager documents, with no mask. Everything else is a warning that says what it could not establish. Enodia does not change the setting, on purpose: that needs root, which nothing else here does, and it fights the daemon that owns the interface. So this catches rather than corrects, the way the lid and the corner names do. [The design notes](docs/design.md) say what each of those warnings is guarding against.
|
|
231
|
+
|
|
232
|
+
## The notebook and the headset button
|
|
233
|
+
|
|
234
|
+

|
|
235
|
+
|
|
236
|
+
Positioning is a paper notebook, no GPS. Each time you pass a street crossing you note the time Enodia just said and the crossing. Afterwards, offline, you transcribe it one crossing per line:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
17:45:00 18 de Julio y Eduardo Acevedo
|
|
240
|
+
17:52 18 de Julio y Yaguarón # seconds are optional
|
|
241
|
+
18:25:00 Plaza Independencia @ -34.9066, -56.2001 # coordinates, if you look them up later
|
|
242
|
+
Plaza Cagancha # no time: takes the next button mark
|
|
243
|
+
#7 Plaza Fabini # button mark 7, when one was skipped
|
|
244
|
+
date 2026-09-06 # the lines below belong to another day
|
|
245
|
+
03:25:00 McDonald's Paso Molino
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Seconds are optional. Coordinates go after an `@`, latitude then longitude, and they are what turns a fraction of a block into a point on a map. Times without a date belong to the day of the first scan in the log, a `date` line switches the day for the lines below it, a date written on a line switches it from there on too, and a time earlier than the previous one rolls over to the next day. A walk goes forwards, so a crossing dated earlier than the one above it is refused rather than placed, and so is a `date` line that would take the walk back past the crossing before it. A `#` starts a comment, except a leading `#7`, which names a button mark.
|
|
249
|
+
|
|
250
|
+
Name each crossing the same way every time. "Avenida Agraciada y San Fructuoso" one week and "San Fructuoso y Avenida Agraciada" the next are two different corners as far as Enodia is concerned. It is caught rather than corrected: `--reconcile` and `--check-map` name the pairs written both ways round and ask you to settle on one spelling, and they do not merge them, because "Treinta y Tres" is one street and not the corner of Treinta and Tres, and no rule can tell those two shapes apart from a name on its own. Case, accents and extra spaces are forgiven.
|
|
251
|
+
|
|
252
|
+
A line that cannot be right stops the reconciliation with its number and its reason rather than being read as best it can. The 31st of February is not a date, a latitude of 999 is not a place on the earth, and `17:0 A` is a time typed wrong rather than a crossing named `17:0 A`. [The design notes](docs/design.md) say why each of those refusals earns its keep.
|
|
253
|
+
|
|
254
|
+
### The headset button
|
|
255
|
+
|
|
256
|
+
The notebook's weak point is the time: heard through headphones, written by hand, one misheard digit moves a whole stretch of the route. A headset has a button, and Linux shows it as an input device that reports every press with the time the kernel stamped it with, which is when the press happened rather than when anything got round to reading it. Press it at each crossing and the time is the machine's. Enodia answers *"Mark 7"*, and the notebook only needs the crossing's name next to that number. When the kernel says its own queue overran, which is a thing it says out loud, Enodia says so too and writes it down: a press that never arrived is a crossing that never arrived, and the numbers carry on afterwards as if nothing had gone missing, so the paper and the log agree with each other and both are short a corner.
|
|
257
|
+
|
|
258
|
+
With marks in the log the notebook can drop the times. A line is just the crossing's name and takes the next mark in order, or `#7 Plaza Fabini` names mark 7 outright when one was pressed by mistake and skipped. Timed and untimed lines can be mixed.
|
|
259
|
+
|
|
260
|
+
By default (`--button auto`) Enodia listens to every input device that has media keys: a headset's play button, and the laptop's own keyboard, which does no harm from inside a closed backpack. `--button /dev/input/eventN` names one device and takes any key on it. `--button list` shows what the kernel sees. `--button off` disables it. Presses closer together than a second count as one, and with `--resume` the marks carry on across a restart of the same outing. [Setting up the machine](docs/setup.md) covers the permissions and how to find out whether your headset's button reaches the kernel at all.
|
|
261
|
+
|
|
262
|
+
## Back home: reconciliation
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
uv run enodia --reconcile networks.jsonl libreta.txt # report on the terminal
|
|
266
|
+
uv run enodia --reconcile networks.jsonl libreta.txt --scans # also the position of every scan
|
|
267
|
+
uv run enodia --reconcile networks.jsonl libreta.txt --pace clock # interpolate on time instead
|
|
268
|
+
uv run enodia --reconcile networks.jsonl libreta.txt --csv redes.csv
|
|
269
|
+
uv run enodia --reconcile networks.jsonl libreta.txt --geojson mapa.geojson
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Every scan is placed between the two crossings it fell between. By default Enodia works out the pace from the scans themselves rather than from the clock: between two scans it measures how much the set of networks in view turned over, which is near zero while you stand still and climbs as access points enter and leave behind you. A stop therefore stays a stop instead of being smeared over half a block, and you no longer have to write the place down twice. Where the scans say nothing about pace it falls back to the clock, and `--pace clock` asks for that everywhere.
|
|
273
|
+
|
|
274
|
+
With coordinates on both crossings a position is interpolated to a latitude and longitude. Without them it is expressed as a fraction of the way between two named crossings, which is enough to draw on a paper map.
|
|
275
|
+
|
|
276
|
+
A notebook is one walk's, so a reconciliation is one walk's too. The usual file holds exactly one and there is nothing to choose. `--log walk.jsonl` reused every week is the other case, and there Enodia reads the last walk in the file and says which one that was, with `--outing TOKEN` to name an older one. Everything that reads a log works this way.
|
|
277
|
+
|
|
278
|
+
### Where you were, and where the access point is
|
|
279
|
+
|
|
280
|
+
Every network gets the position of the scan where its signal was strongest: that is where *you* were. It also gets an estimate of where the *access point* stands, worked out from every place it was heard, which is a different question and a better answer. It is a weighted centroid under the log-distance model, so a sighting counts as `10 ** (RSSI / 10n)` and the unknown transmit power cancels out. With coordinates on the crossings it is a latitude and longitude. Without them it still runs, in the one dimension a notebook always has: how far along a stretch between two named crossings you were.
|
|
281
|
+
|
|
282
|
+
Most of what a walk hears is not on the street it walked, and the estimate still puts a point on the map for it. So each estimate is compared with the plain middle of the same sightings, with the signal ignored. Walk past an access point and its signal peaks sharply, pulling the estimate well clear of that middle. Hear one from a block away and every sighting weighs about the same, the estimate settles on the middle of your own route, and it says nothing whatever about where the thing is. The second kind is marked **barely pinned down**, in the report, in the CSV and in the GeoJSON.
|
|
283
|
+
|
|
284
|
+
[The methodology notes](docs/methodology.md) give the maths, the two biases worth knowing before you draw any of it on a map, and why sightings from two different stretches are never averaged together.
|
|
285
|
+
|
|
286
|
+
## Finding yourself again
|
|
287
|
+
|
|
288
|
+
Reconciling a walk turns a log into a map. The map can be turned back into a compass. Every scan a reconciliation placed is already a fingerprint, the networks in view with their strengths tied to a place, so a map is nothing but those scans kept and searched.
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
uv run enodia --map-add <log> libreta.txt # add an outing to the map
|
|
292
|
+
uv run enodia --locate # scan now: where am I?
|
|
293
|
+
uv run enodia --locate <log> # or locate a log's last scan
|
|
294
|
+
uv run enodia --check-map # and is any of this worth anything?
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
This is scene analysis, not trilateration. It never asks where an access point is, only whether this pattern has been heard before and where, which sidesteps the weakest part of the estimates above and needs no coordinates at all. A notebook of bare crossing names gives answers of the same shape the rest of Enodia speaks in.
|
|
298
|
+
|
|
299
|
+
Four rules do most of the work, and each of them is a way of not answering. **Not knowing is an answer**: below a floor it says "not on the map" rather than guessing confidently in a city it has never been to. **Two streets are never averaged**: when two stretches match about as well, both are reported, because the midpoint of two streets is inside the block between them, where you certainly were not. **A stretch is one stretch whichever way you walked it**: the two crossing names are sorted before anything is stored, and compared with case, accents and spacing folded away. And **a crossing name means one place**: when the fingerprints behind an answer are scattered across a county, the stretch is named and the coordinates are withheld, because two towns can have the same pair of street names. [The design notes](docs/design.md) give each of those its history.
|
|
300
|
+
|
|
301
|
+
What is matched on is which networks are in view, as the same Jaccard measure the pace estimate uses. `--match signal` also weighs how strong each one came in, which is more precise and less portable, since one radio reads several dB apart from another and your own body shadows differently walking one way than the other. Which of the two wins is a question about your streets, not about the method, so `--check-map` answers it with your own data.
|
|
302
|
+
|
|
303
|
+
The map lives in `$XDG_DATA_HOME/enodia/map/map.jsonl`, and `--map FILE` puts it anywhere you like. Adding the same outing twice is refused rather than done, since a doubled outing pulls every answer towards itself. An outing goes on whole or not at all, written through a temporary file whose name nothing can guess, with the map held while it is read, added to and put back, and a map Enodia creates is readable only by you. That last pair matters more in a shared directory than it looks: a predictable temporary is a name somebody else can leave a link under, and then the next outing pours the map through the link into whatever it points at.
|
|
304
|
+
|
|
305
|
+
Be clear about what this file is. It is a geolocation database of your neighbours' routers, names included, keyed to the street corners they sit near, small enough to mail and easy to grep. `--export-public` exists for when you want to share a walk anyway: see *Sharing a walk* below. That is exactly what makes it work and exactly why it is worth keeping to yourself. `.gitignore` excludes `*.jsonl` already. Keeping the SSID is deliberate, because a map you can read by eye is a map you can check.
|
|
306
|
+
|
|
307
|
+
## Does any of it work?
|
|
308
|
+
|
|
309
|
+
Three commands, and all three answer with the walk you already did rather than with an assertion. That is the part worth keeping whichever way the numbers come out.
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
uv run enodia --reconcile <log> libreta.txt --check-pace # reading the pace, against the clock
|
|
313
|
+
uv run enodia --reconcile <log> libreta.txt --check-passes # one block walked twice: do the passes agree?
|
|
314
|
+
uv run enodia --check-map # hold out a whole walk and locate it from the rest
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**`--check-pace`** holds out each crossing in turn, reconciles without it, and measures how far each method puts the scan nearest that crossing's time from where the crossing actually was. The notebook is the only ground truth there is, so it is also the test. If the clock wins on your route the report says so, and `--pace clock` is one flag away.
|
|
318
|
+
|
|
319
|
+
**`--check-passes`** finds any stretch the notebook shows walked more than once and compares what each pass made of it. Walk a block, turn round at the corner and walk it back, and you have left a small controlled experiment inside an ordinary outing: the same access points, the same street, the pace and the direction the only things that changed.
|
|
320
|
+
|
|
321
|
+
**`--check-map`** holds out one whole walk at a time and locates every scan of it from the rest of the map. Holding out one scan at a time would be worthless: its neighbour was taken five seconds and six metres later and sees almost exactly the same networks, so the map would be scoring itself on a copy of the question. The report also says how many of its answers were backed only by the outing the scan came from, because a map built from a single outing will find that outing again beautifully and prove nothing.
|
|
322
|
+
|
|
323
|
+
[The methodology notes](docs/methodology.md) show what each of the three prints, on a synthetic route built to have a known answer, and say what the numbers mean.
|
|
324
|
+
|
|
325
|
+
## Putting the notebook on the map
|
|
326
|
+
|
|
327
|
+
Coordinates are what turn a fraction of a block into a place, and writing thirty crossings out by hand is where a neighbourhood walk stops being fun. `--geocode` looks them up on OpenStreetMap through Overpass and writes a second notebook beside the first.
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
uv run enodia --geocode libreta.txt --area Montevideo
|
|
331
|
+
uv run enodia --geocode libreta.txt --area "s,w,n,e" # or a bounding box
|
|
332
|
+
uv run enodia --geocode libreta.txt --area Montevideo --marks paseo.jsonl # times from the button
|
|
333
|
+
uv run enodia --geocode libreta.txt --area Montevideo --proxy socks5://127.0.0.1:9050
|
|
334
|
+
uv run enodia --geocode libreta.txt --area Montevideo --streets calles.jsonl
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
One request for the whole notebook, an exact-name match rather than a regular expression, and the intersections worked out here rather than there. It never touches the original notebook, it refuses to overwrite a second one you may have finished by hand, and it reports every crossing it could not place with the reason. **Your own walk checks the lookup**: the notebook has the times, so the coordinates imply a speed for every stretch, and nobody covers eight hundred metres in ninety seconds. A stretch that says they did means one of its two corners is in the wrong place, and since there is no telling which, neither is written.
|
|
338
|
+
|
|
339
|
+
`--proxy` sends that one request through an HTTP or SOCKS5 proxy, which is what Tor is (`9050` for the daemon, `9150` for the Browser). It fails closed: if the proxy cannot be reached, nothing is sent and nothing falls back to a direct connection. The proxy resolves the hostname, never this machine, and the environment's proxy variables are deliberately never read, so the destination of the request is in the command you typed.
|
|
340
|
+
|
|
341
|
+
`--streets FILE` keeps the street geometry that same request came back with. Given it, `--reconcile` and `--map-add` place each scan along the street as OpenStreetMap draws it instead of on the straight line between two corners. `--svg plano.svg` then draws the whole walk, with no tiles fetched, so nothing is asked of OpenStreetMap when the picture is opened.
|
|
342
|
+
|
|
343
|
+
Coordinates in your own notebook for your own use are not a problem under the ODbL. Publishing a database derived from OpenStreetMap carries share-alike obligations, which is worth knowing before the map file goes anywhere. [The methodology notes](docs/methodology.md) cover what the lookup refuses and why, and what the drawing does and does not claim.
|
|
344
|
+
|
|
345
|
+
## Sharing a walk
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
uv run enodia --export-public paseo.jsonl libreta.txt --out samples/
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
A publishable copy of one outing. Both files together, because the log is where your
|
|
352
|
+
neighbours' routers are and the notebook is where the street corners are, and a substituted log
|
|
353
|
+
beside a real notebook still says where you were walking and when.
|
|
354
|
+
|
|
355
|
+
An export is exactly one walk. `--log walk.jsonl` reused every week appends to the same pages,
|
|
356
|
+
so a file is not an outing, and a file holding more than one is refused with their names rather
|
|
357
|
+
than resolved to the newest: everywhere else in Enodia guessing the last walk is sensible, and
|
|
358
|
+
here it would mean publishing the walks nobody asked to publish. `--outing TOKEN` names it.
|
|
359
|
+
|
|
360
|
+
What it does: every hardware address is replaced by a stable pseudonym, `ap-1c8a74f992ae`, or
|
|
361
|
+
by a locally administered MAC with `--mac-shaped` for tools that insist on the shape. Network names
|
|
362
|
+
are removed, or pseudonymized with `--ssid pseudonym`, or kept with `--ssid keep`. Crossings are
|
|
363
|
+
renamed street by street, so a street that turns up at two corners is still one street. The
|
|
364
|
+
clock is moved so that the first moment of the two files becomes the epoch and everything keeps
|
|
365
|
+
its distance from it. The coordinates are laid out from an artificial origin in metres, so the
|
|
366
|
+
distances between the crossings come out identical. The association records are left out
|
|
367
|
+
entirely, because the network you were connected to is yours and not a neighbour's, the interface
|
|
368
|
+
name is substituted too, and a failure reason is kept only when Enodia wrote it in its own words. And the
|
|
369
|
+
exported outing still reconciles, which is the point: it is an example of a walk, and if Enodia
|
|
370
|
+
could not read it back it would be an example of nothing.
|
|
371
|
+
|
|
372
|
+
The pseudonyms are an HMAC keyed by a secret made once in `$XDG_CONFIG_HOME/enodia`, so two
|
|
373
|
+
exports months apart give the same router the same name and an experiment can be published in
|
|
374
|
+
parts. `--key-file` names another. Publishing the digest is safe precisely because it is keyed:
|
|
375
|
+
without the key nobody can work out the digest of a guessed network name, which is the whole
|
|
376
|
+
objection to a bare hash of an SSID.
|
|
377
|
+
|
|
378
|
+
**It is pseudonymised and not anonymous**, and the command says so every time it runs. A
|
|
379
|
+
pseudonym is stable, which is what makes the file worth having and what the word anonymous would
|
|
380
|
+
deny. A radio fingerprint locates itself: the set of access points at a corner is that corner's
|
|
381
|
+
identity, which is exactly how `--locate` works, so anybody who walks the same streets with
|
|
382
|
+
their own scanner can join their real addresses onto this. And the shape of the walk survives
|
|
383
|
+
the move, which is what reproducing the numbers needs and what makes the route searchable
|
|
384
|
+
against a map. Read the export before publishing it. That is the only check that counts.
|
|
385
|
+
|
|
386
|
+
The two fields that carry free text out have domains rather than being copied. An `event`
|
|
387
|
+
outside Enodia's own vocabulary is not a record of a walk, so the record is left out, and a
|
|
388
|
+
`security` label the backends are not known to write is withheld. Both are counted and both are
|
|
389
|
+
in the report: a type stops a field nobody named from leaving, and only a domain stops a named
|
|
390
|
+
field carrying whatever a hand-edited line put in it.
|
|
391
|
+
|
|
392
|
+
Before it writes, it searches what it is about to write for every address, network name,
|
|
393
|
+
interface and street name the two source files carry, all the walks in the file and not only
|
|
394
|
+
the one going out, the notebook's comments included, and names anything it finds. It reports
|
|
395
|
+
and does not refuse, because a substring search cannot tell a leak from a coincidence: a
|
|
396
|
+
network called `date` matches every directive of the exported notebook. What it is for is the
|
|
397
|
+
field somebody adds to the log next year and forgets to substitute.
|
|
398
|
+
|
|
399
|
+
`--out` names a directory and the directory is the export: it is built beside where it goes and
|
|
400
|
+
moved into place whole, so `--out` must not already exist, not even as an empty directory, and
|
|
401
|
+
two of these aimed at the same place cannot both think they succeeded. It is written privately
|
|
402
|
+
and opened up only once it is complete. The
|
|
403
|
+
key is created the same way, once, so that two exports starting together cannot end up with one
|
|
404
|
+
walk pseudonymised under a key that no longer exists.
|
|
405
|
+
|
|
406
|
+
The map, the CSV, the GeoJSON, the SVG and the streets file are not exported. They are derived
|
|
407
|
+
from these two, so whoever receives a walk can regenerate them.
|
|
408
|
+
|
|
409
|
+
## Log format
|
|
410
|
+
|
|
411
|
+
The log is [JSON Lines](https://jsonlines.org/): one JSON object per line, appended as the walk goes. Eight kinds of record are written: `connected` and `disconnected` when an interface's association changes, `scan` with every access point seen on each cycle, `new` with the ones seen for the first time, `scan_failed` when the daemon could not scan (with the reason), `suspended` on waking from a suspend (with the seconds asleep), `mark` on each press of the headset button (with its number), and `button_lost` when the kernel says its input queue overran and a press may have gone missing. Only `scan` records count as observations.
|
|
412
|
+
|
|
413
|
+
```
|
|
414
|
+
{"time": "2026-09-05T00:14:03-03:00", "event": "scan", "outing": "3f9a2b10", "interface": "wlan0", "networks": [{"ssid": "Home", "bssid": "aa:bb:cc:dd:ee:ff", "security": "psk", "frequency": 5180, "signal_dbm": -47, "signal_percent": 100, "connected": true}, {"ssid": "Cafe libre", "bssid": null, "security": "open", "frequency": null, "signal_dbm": null, "signal_percent": 38, "connected": false}]}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
One line per record is what the format is for. The log is written from a laptop that may lose power mid-line, and a line that was never finished costs only itself. And the most hostile thing in the file is the SSIDs, which whoever owns the network chooses and which may hold commas, quotes or newlines. JSON escapes all of it, and accented and non-Latin names stay readable because nothing is escaped into `\u` sequences.
|
|
418
|
+
|
|
419
|
+
Frequencies are in MHz, as the backend reports them, not channel numbers: a channel is derived and ambiguous across bands, and which band an access point is on is what says how far a given signal strength puts it. Anything a backend cannot report is `null`, not a placeholder to parse back, with one exception: a network that hides its name is written `"ssid": ""`, because that is how the reader will hand it back and the file says what it is going to mean rather than something that has to be turned into it. Every record carries the `outing` it belongs to, a token for one run of the loop, and a scan record also carries the `cycle`, the pass of the loop it came from. Both exist because the clock has one second of resolution and two of anything can share a second. [The design notes](docs/design.md) tell that story, along with what is checked when a log is read back and why.
|
|
420
|
+
|
|
421
|
+
Because every record is a line of JSON, the log works with ordinary tools:
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
jq -r 'select(.event=="scan") | .networks[] | select(.security=="open") | .ssid' 2026-09-14T17-45-03.jsonl | sort -u
|
|
425
|
+
wc -l 2026-09-14T17-45-03.jsonl # how many records the outing produced
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
## From Python
|
|
429
|
+
|
|
430
|
+
```python
|
|
431
|
+
from enodia import BackgroundVoice, ESpeak, NetworkLog, VoiceController, WifiMonitor
|
|
432
|
+
|
|
433
|
+
with WifiMonitor(
|
|
434
|
+
voice=BackgroundVoice(VoiceController(ESpeak())), log=NetworkLog("walk.jsonl")
|
|
435
|
+
) as monitor:
|
|
436
|
+
monitor.scan_networks() # one cycle. Returns the networks seen for the first time
|
|
437
|
+
monitor.scan_networks_loop() # until interrupted, or until stop() from another thread
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
| Module | Entry points |
|
|
441
|
+
|---|---|
|
|
442
|
+
| `enodia.monitor` | `WifiMonitor`, with `scan_networks()`, `scan_networks_loop()`, `auto_scan()`, `use_button()`, `mark()`, `resume_from_log()`, and `close()` (also a context manager) |
|
|
443
|
+
| `enodia.netlog` | `NetworkLog(path)` writes. `read_log(path)` parses, skipping any line that is not a whole JSON object. `outings(records)` and `records_for_outing(records, outing)` pick one walk out of a file. `find_open_networks(path)` |
|
|
444
|
+
| `enodia.voice` | `VoiceController` speaks now. `BackgroundVoice(controller)` queues, dropping utterances marked `optional` when it falls behind. `ESpeak`, `PicoTTS` |
|
|
445
|
+
| `enodia.button` | `ButtonMarker(devices, on_press)`, `find_button_devices()`, `list_input_devices()`, `event_age(at)` |
|
|
446
|
+
| `enodia.reconcile` | `reconcile(log, notebook, by_movement=True, outing=None)`, `check_pace(...)`, `check_passes(...)`, `read_notebook(path, day, tz, marks)`, `network_turnover(a, b)`, `place_by_movement(scans, waypoints)`, `signal_weight(dbm, exponent)` |
|
|
447
|
+
| `enodia.fingerprint` | `add_to_map(map, log, notebook)`, `read_map(path)`, `locate_scan(fingerprints, networks)`, `check_map(fingerprints)`, `scan_now(interface)`, `canonical(a, b, fraction)` |
|
|
448
|
+
| `enodia.geocode` | `geocode_notebook(notebook, area)`, `read_crossings(path)`, `junction_of(a, b, places)`, `overpass_query(streets, area)`, `parse_proxy(url)` |
|
|
449
|
+
| `enodia.draw` | `svg_map(result, streets)`, `Frame.around(places)` |
|
|
450
|
+
| `enodia.streets` | `read_streets(path)`, `write_streets(path, streets)`, `StreetMap.between(here, there)`, `point_along(line, fraction)` |
|
|
451
|
+
| `enodia.preflight` | `run_preflight(...)`, `format_preflight(checks, color)` |
|
|
452
|
+
| `enodia.system` | `session_log_path(directory)`, `data_dir()`, `map_path()`, `battery()`, `lid_switch_setting()` and `scan_mac_setting()`, each answering with what it read and with the files it could not |
|
|
453
|
+
|
|
454
|
+
## Development
|
|
455
|
+
|
|
456
|
+
The `dev` dependency group (pytest, ruff, ty) is installed by `uv sync` unless you pass `--no-dev`. `ruff check` and `ruff format --check` lint and format, `ty check` checks the types, and `pytest` runs the suite with coverage. CI (`.github/workflows/tests.yml`) runs all four on Python 3.10 to 3.14. No test touches this machine's radio or input devices: `tests/conftest.py` refuses any ifpeek call or device listing a test has not stood in for.
|
|
457
|
+
|
|
458
|
+
## Further reading
|
|
459
|
+
|
|
460
|
+
- [Every flag](docs/cli.md), grouped by when you reach for it.
|
|
461
|
+
- [Setting up the machine](docs/setup.md): the lid, and permission to read the headset button.
|
|
462
|
+
- [Why Enodia is deliberately conservative](docs/design.md): what the program refuses to do, and the mistake that taught it to refuse. It is the longest document here and the one worth reading if you want to know why any of this is shaped the way it is.
|
|
463
|
+
|
|
464
|
+
## License
|
|
465
|
+
|
|
466
|
+
MIT.
|