DownloadCWN MeshCore Node Mapping
> Draft ? This proposal was generated by AI and may not have been reviewed for accuracy.
Overview
Extend the Community Wireless Node List (CWN) WebDoor to automatically ingest node
advertisements heard by the binkterm-php MeshCore bridge. When the bridge hears a
MeshCore advert (either a live push or a startup contact-list dump), it POSTs the node
data to a new BBS API endpoint, which UPSERTs the record into cwn_networks. Nodes
auto-expire if not heard for 2 days.
The bridge lives in a separate repository (binktermphp-meshcorebridge). Changes are
needed in both repos.
Design Decisions (already settled)
| # | Decision |
|---|----------|
| 1 | Skip nodes with invalid/missing coordinates. Only ingest if has_location is true and lat/lon pass range validation. |
| 2 | 2-day rolling expiry. MeshCore-sourced nodes are hidden from map/list queries when last_seen_at < NOW() - INTERVAL '2 days'. They are not hard-deleted. |
| 3 | Contact-list dump also pushed. The startup contacts_start / contact / contacts_end sequence should be forwarded too, not only live new_advert pushes. |
| 4 | Repeaters only. Only nodes with adv_type_name === 'repeater' (type 2) are ingested. Chat/companion nodes (type 1), room servers (type 3), and sensors (type 4) are silently skipped. Filter is applied in the bridge before the HTTP call is made. |
Data Flow
MeshCore radio
?
? new_advert (0x8A push) OR contact record (0x03, from startup dump)
?
MeshCoreBridge::handleFrame() [binktermphp-meshcorebridge]
?
? if has_location && coords valid
?
BbsApiClient::reportAdvert(array $data)
? POST /api/meshcore/advert
? Authorization: Bearer <api_key>
?
BBS route handler [binkterm-php]
? authenticates via requirePacketBbsAuth($bridgeNodeId)
? (same auth helper already used by /api/packetbbs/command)
?
UPSERT cwn_networks WHERE public_key = $pubKeyHex
action = 'created' | 'updated'
Database Migration
Check the current highest migration version with:
ls database/migrations/ | sort -V | tail -5
Name the new file v1.11.0.87_cwn_meshcore_nodes.sql (one increment above the current max).
-- Add MeshCore / machine-ingest support to cwn_networks
ALTER TABLE cwn_networks
ALTER COLUMN submitted_by DROP NOT NULL,
ALTER COLUMN submitted_by_username DROP NOT NULL,
ALTER COLUMN description DROP NOT NULL;
ALTER TABLE cwn_networks
ADD COLUMN IF NOT EXISTS public_key VARCHAR(64), -- full 32-byte pub key as hex (64 chars)
ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) NOT NULL DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS hop_count SMALLINT;
-- Partial unique index: one CWN row per MeshCore node (allows NULLs for manual entries)
CREATE UNIQUE INDEX IF NOT EXISTS idx_cwn_networks_public_key
ON cwn_networks(public_key)
WHERE public_key IS NOT NULL;
Why submitted_by and description become nullable: machine-ingested entries have
no human submitter and no human-written description. All existing manual rows keep their
NOT NULL values; only new auto-ingest rows will carry NULLs here.
New BBS API Endpoint
File: routes/packetbbs-routes.php (reuses the existing requirePacketBbsAuth() helper)
POST /api/meshcore/advert
Authorization: Bearer <api_key>
Content-Type: application/json
Request body
{
"bridge_node_id": "aabbccddeeff",
"pub_key_hex": "aabbccddeeff001122334455667788990011223344556677889900112233445566",
"name": "NodeName",
"adv_type": "chat",
"latitude": 37.123456,
"longitude": -122.123456,
"hop_count": 0,
"timestamp_iso": "2026-05-01T12:34:56Z"
}
Fields name and adv_type may be empty strings if the radio didn't include them.
hop_count is the out_path_len from the decoded packet (0 = direct neighbour).
Validation
-
Reject if `latitude` outside [-90, 90] or `longitude` outside [-180, 180].
-
Reject if `pub_key_hex` is not exactly 64 lowercase hex characters.
UPSERT logic
INSERT INTO cwn_networks
(public_key, ssid, latitude, longitude, source_type, hop_count, last_seen_at,
network_type, bbs_name)
VALUES
(:pub_key_hex, :name, :lat, :lon, 'meshcore', :hops, NOW(), :adv_type, :bbs_name)
ON CONFLICT (public_key) WHERE public_key IS NOT NULL DO UPDATE SET
ssid = EXCLUDED.ssid,
latitude = EXCLUDED.latitude,
longitude = EXCLUDED.longitude,
hop_count = EXCLUDED.hop_count,
network_type = EXCLUDED.network_type,
last_seen_at = NOW(),
date_updated = NOW()
RETURNING id, (xmax = 0) AS was_inserted
was_inserted is true on INSERT, false on UPDATE ? use this to build the action field
in the JSON response.
The bbs_name is fetched server-side from BinkpConfig::getInstance()->getSystemName().
Response
{ "success": true, "action": "created", "network_id": 42 }
Bridge Changes (binktermphp-meshcorebridge)
src/BbsApiClient.php ? new method
/
* Report a MeshCore node advertisement to the BBS CWN mapper.
*
* @param array{
* pub_key_hex: string,
* name: string,
* adv_type: string,
* latitude: float,
* longitude: float,
* hop_count: int,
* timestamp_iso: string
* } $data
*/
public function reportAdvert(array $data): bool
{
$data['bridge_node_id'] = $this->bridgeNodeId ?? '';
$response = $this->post('/api/meshcore/advert', json_encode($data));
return $response !== null;
}
src/MeshCoreBridge.php ? handleFrame() additions
Add two new cases to the switch on $packet['type']:
case 'new_advert':
$this->maybeReportAdvert($packet);
break;
case 'contact':
$this->maybeReportAdvert($packet);
break;
Add a private helper:
private function maybeReportAdvert(array $packet): void
{
// Only ingest repeater nodes
if (($packet['adv_type_name'] ?? '') !== 'repeater') {
return;
}
$lat = (float)($packet['adv_lat_deg'] ?? 0.0);
$lon = (float)($packet['adv_lon_deg'] ?? 0.0);
// Reject zero coords and out-of-range values
if ($lat === 0.0 && $lon === 0.0) {
return;
}
if ($lat < -90.0 || $lat > 90.0 || $lon < -180.0 || $lon > 180.0) {
return;
}
$pubKey = (string)($packet['pub_key_hex'] ?? '');
if (strlen($pubKey) !== 64) {
return;
}
$ok = $this->api->reportAdvert([
'pub_key_hex' => $pubKey,
'name' => (string)($packet['adv_name'] ?? ''),
'adv_type' => (string)($packet['adv_type_name'] ?? ''),
'latitude' => $lat,
'longitude' => $lon,
'hop_count' => (int)($packet['out_path_len'] ?? 0),
'timestamp_iso' => (string)($packet['lastmod_iso'] ?? $packet['last_advert_iso'] ?? gmdate('c')),
]);
if (!$ok && !empty($this->config['debug'])) {
$this->log(sprintf(
'advert report failed for %s: %s',
substr($pubKey, 0, 12),
$this->api->getLastError() ?? 'unknown error'
));
}
}
Note on the zero-coordinate check: lat === 0.0 && lon === 0.0 catches the common
case of a node that has never acquired GPS (MeshCore stores 0,0 when no fix). Per the
design decision, these are silently skipped.
CWN WebDoor Changes
public_html/webdoors/cwn/api.php
handleListNetworks() ? add expiry filter for meshcore entries:
AND (
source_type != 'meshcore'
OR last_seen_at > NOW() - INTERVAL '2 days'
)
handleSearch() / searchByRadius() / searchByKeyword() ? same expiry clause.
handleSubmitNetwork() ? no change; manual submissions continue to require all
fields including description and will continue to have submitted_by set.
public_html/webdoors/cwn/js/cwn.js
Map markers: use a different icon for meshcore entries vs manual entries. A simple
approach is a coloured divIcon based on source_type:
function markerIconFor(network) {
const isMesh = network.source_type === 'meshcore';
return L.divIcon({
className: '',
html: `<i class="fas ${isMesh ? 'fa-broadcast-tower' : 'fa-wifi'}"
style="font-size:20px;color:${isMesh ? '#0dcaf0' : '#0d6efd'}"></i>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
Detail view (displayNetworkDetails()): add:
- source_type badge (meshcore = cyan badge-info, manual = blue badge-primary)
- public_key row: display first 12 chars with full key on title tooltip and a copy button
- hop_count row (only shown for meshcore entries)
- last_seen_at row replacing "Last Verified" for meshcore entries
List items: append a small [mesh] badge next to the node name when
source_type === 'meshcore'.
public_html/webdoors/cwn/index.html
No structural changes required beyond what cwn.js injects dynamically. Verify the
details modal <dl> is flexible enough to accommodate extra rows (it is ? rows are
built in JS).
Files to Touch (summary)
binkterm-php repo
| File | Change |
|------|--------|
| database/migrations/v1.11.0.87_cwn_meshcore_nodes.sql | New migration |
| routes/packetbbs-routes.php | New POST /api/meshcore/advert route |
| public_html/webdoors/cwn/api.php | Add 2-day expiry clause to list/search queries |
| public_html/webdoors/cwn/js/cwn.js | Mesh-aware icons, public key in detail view |
binktermphp-meshcorebridge repo
| File | Change |
|------|--------|
| src/BbsApiClient.php | Add reportAdvert() method |
| src/MeshCoreBridge.php | Handle new_advert and contact packets; add maybeReportAdvert() |
Open Items / Not In Scope
-
Admin view of meshcore nodes: currently sysadmins can soft-delete any row via the
existing delete endpoint; no dedicated admin UI planned for this phase.
-
Hard expiry cleanup: rows are hidden by the 2-day query filter but not deleted.
A future maintenance script or cron job could prune them.
-
Signal quality (SNR): V3 packets carry SNR. Not captured in this phase; could be
added as a `snr` column later.
-
Federation: the `bbs_name` column already supports future inter-BBS federation;
no federation work in this phase.
|