node-red-zenbus 1.0.9 → 1.0.10

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.
package/icons/zenbus.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "node-red-zenbus",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "description": "Real-time next bus ETA from Zenbus networks via direct API (protobuf)",
5
5
  "type": "commonjs",
6
- "bin": {
7
- "zenbus-next-bus": "./zenbus-next-bus.mjs"
8
- },
9
- "main": "zenbus-core.mjs",
6
+ "main": "zenbus-next-bus.js",
10
7
  "node-red": {
11
8
  "version": ">=2.0.0",
12
9
  "nodes": {
13
- "zenbus-next-bus": "zenbus-node-red.cjs"
10
+ "zenbus-next-bus": "zenbus-next-bus.js"
14
11
  },
15
12
  "examples": {
16
13
  "zenbus-next-bus": [
@@ -18,26 +15,22 @@
18
15
  ]
19
16
  }
20
17
  },
21
- "scripts": {
22
- "start": "node zenbus-next-bus.mjs --alias gpso --itinerary 5426824545828864 --stop 5366312231501824"
23
- },
24
18
  "files": [
25
- "zenbus-core.mjs",
26
- "zenbus-next-bus.mjs",
27
- "zenbus-node-red.cjs",
19
+ "zenbus-next-bus.js",
28
20
  "zenbus-next-bus.html",
29
21
  "icons",
30
- "examples"
22
+ "examples",
23
+ "LICENSE",
24
+ "README.md"
31
25
  ],
32
26
  "keywords": [
27
+ "node-red",
33
28
  "zenbus",
34
29
  "bus",
35
30
  "transit",
36
31
  "real-time",
37
32
  "eta",
38
- "protobuf",
39
- "cli",
40
- "node-red"
33
+ "protobuf"
41
34
  ],
42
35
  "author": "gautric",
43
36
  "license": "MIT",
@@ -53,7 +46,6 @@
53
46
  "node": ">=18"
54
47
  },
55
48
  "dependencies": {
56
- "protobufjs": "^8.0.1",
57
- "yargs": "^17.7.2"
49
+ "protobufjs": "^8.0.1"
58
50
  }
59
51
  }
@@ -1,7 +1,7 @@
1
1
  <script type="text/javascript">
2
2
  RED.nodes.registerType('zenbus-next-bus', {
3
- category: 'location',
4
- color: '#DEB887',
3
+ category: 'network',
4
+ color: '#5BA8A8',
5
5
  defaults: {
6
6
  name: { value: '' },
7
7
  alias: { value: '', required: true },
@@ -11,14 +11,14 @@
11
11
  },
12
12
  inputs: 0,
13
13
  outputs: 1,
14
- icon: 'zenbus.svg',
14
+ icon: 'zenbus.png',
15
15
  label: function () {
16
16
  return this.name || 'zenbus ' + (this.alias || '');
17
17
  },
18
18
  labelStyle: function () {
19
19
  return this.name ? 'node_label_italic' : '';
20
20
  },
21
- paletteLabel: 'zenbus next bus',
21
+ paletteLabel: 'zenbus',
22
22
  outputLabels: ['bus ETA data']
23
23
  });
24
24
  </script>
@@ -0,0 +1,169 @@
1
+ var protobuf = require('protobufjs');
2
+
3
+ var BASE = 'https://zenbus.net';
4
+
5
+ function fetchProto(url, Type) {
6
+ return fetch(url)
7
+ .then(function (r) { return r.arrayBuffer(); })
8
+ .then(function (ab) { return Type.decode(Buffer.from(ab)); });
9
+ }
10
+
11
+ function secsToHHMM(secs) {
12
+ var h = Math.floor(secs / 3600);
13
+ var m = Math.floor((secs % 3600) / 60);
14
+ return h + 'h' + String(m).padStart(2, '0');
15
+ }
16
+
17
+ function createClient(opts) {
18
+ var alias = opts.alias, itinerary = opts.itinerary, stop = opts.stop;
19
+ return fetch(BASE + '/poll/cdn/zenbus.proto')
20
+ .then(function (r) { return r.text(); })
21
+ .then(function (protoText) {
22
+ var root = protobuf.parse(protoText).root;
23
+ var StaticMessage = root.lookupType('zenbus_realtime.StaticMessage');
24
+ var LiveMessage = root.lookupType('zenbus_realtime.LiveMessage');
25
+
26
+ return fetchProto(BASE + '/publicapp/static-data?alias=' + alias, StaticMessage)
27
+ .then(function (staticData) {
28
+ var shape = (staticData.shape || []).find(function (s) { return s.itineraryId && s.itineraryId.toString() === itinerary; });
29
+ var stopAnchor = shape && (shape.anchor || []).find(function (a) { return a.stopId && a.stopId.toString() === stop; });
30
+ var stopIndex = stopAnchor ? (stopAnchor.stopIndexInItinerary != null ? stopAnchor.stopIndexInItinerary : -1) : -1;
31
+ var stopDistanceM = stopAnchor ? (stopAnchor.distanceTravelled || 0) : 0;
32
+ var stopEntry = (staticData.stop || []).find(function (s) { return s.stopId && s.stopId.toString() === stop; });
33
+ var stopName = stopEntry ? stopEntry.name : 'Unknown';
34
+ var itin = (staticData.itinerary || []).find(function (i) { return i.itineraryId && i.itineraryId.toString() === itinerary; });
35
+ var lineEntry = (staticData.line || []).find(function (l) { return itin && l.lineId && l.lineId.toString() === (itin.lineId && itin.lineId.toString()); });
36
+ var lineCode = lineEntry ? lineEntry.code : 'Unknown';
37
+ var pollUrl = BASE + '/publicapp/poll?alias=' + alias + '&itinerary=' + itinerary;
38
+
39
+ return {
40
+ stopName: stopName,
41
+ lineCode: lineCode,
42
+ poll: function () {
43
+ return fetchProto(pollUrl, LiveMessage).then(function (liveData) {
44
+ var now = new Date();
45
+ var midnightUtcSecs = (liveData.timetable && liveData.timetable[0] && liveData.timetable[0].midnight && liveData.timetable[0].midnight.toNumber)
46
+ ? liveData.timetable[0].midnight.toNumber()
47
+ : Math.floor(new Date(now).setHours(0, 0, 0, 0) / 1000);
48
+ var nowSecs = Math.floor(now.getTime() / 1000) - midnightUtcSecs;
49
+
50
+ var allColumns = (liveData.tripColumn || []).slice();
51
+ (liveData.timetable || []).forEach(function (tt) {
52
+ (tt.column || []).forEach(function (col) { allColumns.push(col); });
53
+ });
54
+
55
+ var candidates = [];
56
+ allColumns.forEach(function (tc) {
57
+ var est = (tc.estimactual || []).find(function (s) { return s.stopIndexInItinerary === stopIndex; });
58
+ var etaSecs = est ? (est.arrival || est.departure || 0) : 0;
59
+ if (!etaSecs || etaSecs <= nowSecs) return;
60
+
61
+ var vehicleDist = tc.distanceTravelled || 0;
62
+ var hasStarted = tc.previousIndexInItinerary >= 0 && tc.pos && tc.pos.length > 0;
63
+ if (hasStarted && vehicleDist > stopDistanceM) return;
64
+
65
+ var remainingDist = hasStarted ? Math.max(0, stopDistanceM - vehicleDist) : stopDistanceM;
66
+ var aimed = (tc.aimed || []).find(function (s) { return s.stopIndexInItinerary === stopIndex; });
67
+ var schedSecs = aimed ? (aimed.arrival || aimed.departure || aimed.arriparture || 0) : 0;
68
+
69
+ candidates.push({
70
+ etaMinutes: Math.round((etaSecs - nowSecs) / 60),
71
+ distanceM: Math.round(remainingDist),
72
+ estimatedArrival: secsToHHMM(etaSecs),
73
+ scheduledTime: schedSecs ? secsToHHMM(schedSecs) : null,
74
+ isLive: hasStarted
75
+ });
76
+ });
77
+
78
+ candidates.sort(function (a, b) { return a.etaMinutes - b.etaMinutes; });
79
+ var best = candidates[0] || null;
80
+ var second = candidates.find(function (c, i) { return i > 0 && c.isLive; }) || null;
81
+
82
+ return {
83
+ stop: stopName,
84
+ line: lineCode,
85
+ timestamp: now.toISOString(),
86
+ now: secsToHHMM(nowSecs),
87
+ next: best,
88
+ secondBus: second
89
+ };
90
+ });
91
+ }
92
+ };
93
+ });
94
+ });
95
+ }
96
+
97
+ module.exports = function (RED) {
98
+
99
+ function ZenbusNextBusNode(config) {
100
+ RED.nodes.createNode(this, config);
101
+ var node = this;
102
+ var timer = null;
103
+ var client = null;
104
+ var closing = false;
105
+ var interval = (parseInt(config.interval, 10) || 10) * 1000;
106
+
107
+ node.status({ fill: 'yellow', shape: 'ring', text: 'initializing...' });
108
+
109
+ createClient({
110
+ alias: config.alias,
111
+ itinerary: config.itinerary,
112
+ stop: config.stop
113
+ }).then(function (c) {
114
+ if (closing) return;
115
+ client = c;
116
+ node.log('Client ready – stop=' + client.stopName + ' line=' + client.lineCode);
117
+ node.status({ fill: 'green', shape: 'ring', text: 'connected' });
118
+ scheduleTick();
119
+ }).catch(function (e) {
120
+ node.error('Init failed: ' + e.message);
121
+ node.status({ fill: 'red', shape: 'ring', text: 'init failed' });
122
+ });
123
+
124
+ function scheduleTick() {
125
+ if (closing) return;
126
+ tick().then(function () {
127
+ if (!closing) timer = setTimeout(scheduleTick, interval);
128
+ });
129
+ }
130
+
131
+ function tick() {
132
+ if (!client || closing) return Promise.resolve();
133
+ return client.poll().then(function (data) {
134
+ node.send({ payload: data });
135
+ if (data.next) {
136
+ node.status({
137
+ fill: data.next.isLive ? 'green' : 'yellow',
138
+ shape: 'dot',
139
+ text: data.next.etaMinutes + ' min (' + data.next.estimatedArrival + ')'
140
+ });
141
+ } else {
142
+ node.status({ fill: 'grey', shape: 'ring', text: 'no bus' });
143
+ }
144
+ }).catch(function (e) {
145
+ node.error('Poll error: ' + e.message, {});
146
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
147
+ });
148
+ }
149
+
150
+ node.on('input', function (msg, send, done) {
151
+ send = send || function () { node.send.apply(node, arguments); };
152
+ done = done || function (err) { if (err) node.error(err, msg); };
153
+ if (!client) { done(new Error('Client not initialized')); return; }
154
+ client.poll().then(function (data) {
155
+ send({ payload: data });
156
+ done();
157
+ }).catch(function (e) { done(e); });
158
+ });
159
+
160
+ node.on('close', function (removed, done) {
161
+ closing = true;
162
+ if (timer) { clearTimeout(timer); timer = null; }
163
+ client = null;
164
+ done();
165
+ });
166
+ }
167
+
168
+ RED.nodes.registerType('zenbus-next-bus', ZenbusNextBusNode);
169
+ };
package/icons/zenbus.svg DELETED
@@ -1,9 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 60" width="40" height="60">
2
- <path d="M20 0C9 0 0 9 0 20c0 15 20 40 20 40s20-25 20-40C40 9 31 0 20 0z" fill="#8e6538"/>
3
- <rect x="10" y="10" width="20" height="18" rx="3" fill="#fff"/>
4
- <rect x="12" y="13" width="7" height="6" rx="1" fill="#8e6538"/>
5
- <rect x="21" y="13" width="7" height="6" rx="1" fill="#8e6538"/>
6
- <rect x="10" y="22" width="20" height="4" fill="#fff"/>
7
- <circle cx="14" cy="29" r="2" fill="#fff"/>
8
- <circle cx="26" cy="29" r="2" fill="#fff"/>
9
- </svg>
package/zenbus-core.mjs DELETED
@@ -1,82 +0,0 @@
1
- import protobuf from 'protobufjs';
2
-
3
- const BASE = 'https://zenbus.net';
4
-
5
- async function fetchProto(url, Type) {
6
- const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
7
- return Type.decode(buf);
8
- }
9
-
10
- function secsToHHMM(secs) {
11
- const h = Math.floor(secs / 3600);
12
- const m = Math.floor((secs % 3600) / 60);
13
- return `${h}h${String(m).padStart(2, '0')}`;
14
- }
15
-
16
- export async function createClient({ alias, itinerary, stop }) {
17
- const protoText = await (await fetch(`${BASE}/poll/cdn/zenbus.proto`)).text();
18
- const { root } = protobuf.parse(protoText);
19
- const StaticMessage = root.lookupType('zenbus_realtime.StaticMessage');
20
- const LiveMessage = root.lookupType('zenbus_realtime.LiveMessage');
21
-
22
- const staticData = await fetchProto(`${BASE}/publicapp/static-data?alias=${alias}`, StaticMessage);
23
- const shape = staticData.shape?.find(s => s.itineraryId?.toString() === itinerary);
24
- const stopAnchor = shape?.anchor?.find(a => a.stopId?.toString() === stop);
25
- const stopIndex = stopAnchor?.stopIndexInItinerary ?? -1;
26
- const stopDistanceM = stopAnchor?.distanceTravelled ?? 0;
27
- const stopName = staticData.stop?.find(s => s.stopId?.toString() === stop)?.name || 'Unknown';
28
- const itin = staticData.itinerary?.find(i => i.itineraryId?.toString() === itinerary);
29
- const lineCode = staticData.line?.find(l => l.lineId?.toString() === itin?.lineId?.toString())?.code || 'Unknown';
30
-
31
- const pollUrl = `${BASE}/publicapp/poll?alias=${alias}&itinerary=${itinerary}`;
32
-
33
- return {
34
- stopName, lineCode,
35
- async poll() {
36
- const liveData = await fetchProto(pollUrl, LiveMessage);
37
- const now = new Date();
38
- const midnightUtcSecs = liveData.timetable?.[0]?.midnight?.toNumber?.()
39
- ?? Math.floor(new Date(now).setHours(0, 0, 0, 0) / 1000);
40
- const nowSecs = Math.floor(now.getTime() / 1000) - midnightUtcSecs;
41
-
42
- const allColumns = [...(liveData.tripColumn || [])];
43
- for (const tt of liveData.timetable || []) {
44
- for (const col of tt.column || []) allColumns.push(col);
45
- }
46
-
47
- const candidates = [];
48
- for (const tc of allColumns) {
49
- const est = tc.estimactual?.find(s => s.stopIndexInItinerary === stopIndex);
50
- const etaSecs = est?.arrival || est?.departure || 0;
51
- if (!etaSecs || etaSecs <= nowSecs) continue;
52
-
53
- const vehicleDist = tc.distanceTravelled || 0;
54
- const hasStarted = tc.previousIndexInItinerary >= 0 && tc.pos?.length > 0;
55
- if (hasStarted && vehicleDist > stopDistanceM) continue;
56
-
57
- const remainingDist = hasStarted ? Math.max(0, stopDistanceM - vehicleDist) : stopDistanceM;
58
- const aimed = tc.aimed?.find(s => s.stopIndexInItinerary === stopIndex);
59
- const schedSecs = aimed?.arrival || aimed?.departure || aimed?.arriparture || 0;
60
-
61
- candidates.push({
62
- etaMinutes: Math.round((etaSecs - nowSecs) / 60),
63
- distanceM: Math.round(remainingDist),
64
- estimatedArrival: secsToHHMM(etaSecs),
65
- scheduledTime: schedSecs ? secsToHHMM(schedSecs) : null,
66
- isLive: hasStarted,
67
- });
68
- }
69
-
70
- candidates.sort((a, b) => a.etaMinutes - b.etaMinutes);
71
- const best = candidates[0] || null;
72
- const second = candidates.find((c, i) => i > 0 && c.isLive) || null;
73
-
74
- return {
75
- stop: stopName, line: lineCode,
76
- timestamp: now.toISOString(),
77
- now: secsToHHMM(nowSecs),
78
- next: best, secondBus: second,
79
- };
80
- },
81
- };
82
- }
@@ -1,50 +0,0 @@
1
- #!/usr/bin/env node
2
- import yargs from 'yargs';
3
- import { hideBin } from 'yargs/helpers';
4
- import { createClient } from './zenbus-core.mjs';
5
-
6
- const argv = yargs(hideBin(process.argv))
7
- .env('ZENBUS')
8
- .option('alias', { type: 'string', demandOption: true, describe: 'Network alias (e.g. gpso)' })
9
- .option('itinerary', { type: 'string', demandOption: true, describe: 'Itinerary ID' })
10
- .option('stop', { type: 'string', demandOption: true, describe: 'Stop ID' })
11
- .option('interval', { type: 'number', default: 10, describe: 'Poll interval in seconds' })
12
- .option('output', { type: 'string', choices: ['terminal', 'json'], default: 'terminal', describe: 'Output format' })
13
- .strict()
14
- .parseSync();
15
-
16
- const client = await createClient(argv);
17
-
18
- function renderTerminal(data) {
19
- process.stdout.write('\x1B[2J\x1B[H');
20
- console.log('🚌 ZENBUS - Live Stream (Ctrl+C to quit)\n');
21
- console.log('═══════════════════════════════════════');
22
- console.log(` 🚏 Stop: ${data.stop}`);
23
- console.log(` 🚌 Line: ${data.line}`);
24
- if (data.next) {
25
- const n = data.next;
26
- console.log(` ⏱️ ETA: ${n.etaMinutes} min${n.isLive ? ' (real-time)' : ' (scheduled)'}`);
27
- console.log(` 📏 Distance: ${n.distanceM} m away`);
28
- console.log(` 🕐 Arrival: ${n.estimatedArrival}`);
29
- if (n.scheduledTime) console.log(` 📅 Scheduled: ${n.scheduledTime}`);
30
- if (data.secondBus) {
31
- const s = data.secondBus;
32
- console.log(` ───────────────────────────────────`);
33
- console.log(` 2️⃣ 2nd bus: ${s.etaMinutes} min (${s.distanceM} m away)`);
34
- console.log(` 🕐 Arrival: ${s.estimatedArrival}`);
35
- }
36
- } else {
37
- console.log(` ⏱️ ETA: No upcoming bus`);
38
- }
39
- console.log(` 🕑 Now: ${data.now}`);
40
- console.log('═══════════════════════════════════════');
41
- }
42
-
43
- const render = argv.output === 'json'
44
- ? (data) => console.log(JSON.stringify(data))
45
- : renderTerminal;
46
-
47
- while (true) {
48
- try { render(await client.poll()); } catch (e) { console.error('⚠️', e.message); }
49
- await new Promise(r => setTimeout(r, argv.interval * 1000));
50
- }
@@ -1,59 +0,0 @@
1
- module.exports = function (RED) {
2
- function ZenbusNextBusNode(config) {
3
- RED.nodes.createNode(this, config);
4
- const node = this;
5
- let timer = null;
6
-
7
- const interval = (config.interval || 10) * 1000;
8
-
9
- node.status({ fill: 'yellow', shape: 'ring', text: 'connecting...' });
10
-
11
- import('./zenbus-core.mjs').then(({ createClient }) => {
12
- createClient({
13
- alias: config.alias,
14
- itinerary: config.itinerary,
15
- stop: config.stop,
16
- }).then(client => {
17
- node.log('Zenbus client initialized for ' + config.alias);
18
-
19
- async function tick() {
20
- try {
21
- const data = await client.poll();
22
- node.send({ payload: data });
23
- if (data.next) {
24
- const text = data.next.etaMinutes + ' min';
25
- node.status({
26
- fill: data.next.isLive ? 'green' : 'yellow',
27
- shape: 'dot',
28
- text: text,
29
- });
30
- } else {
31
- node.status({ fill: 'grey', shape: 'ring', text: 'no bus' });
32
- }
33
- } catch (e) {
34
- node.error(e.message);
35
- node.status({ fill: 'red', shape: 'ring', text: 'error' });
36
- }
37
- timer = setTimeout(tick, interval);
38
- }
39
- tick();
40
- }).catch(e => {
41
- node.error('Init failed: ' + e.message);
42
- node.status({ fill: 'red', shape: 'ring', text: 'init failed' });
43
- });
44
- });
45
-
46
- node.on('close', function (removed, done) {
47
- if (timer) {
48
- clearTimeout(timer);
49
- timer = null;
50
- }
51
- if (removed) {
52
- node.log('Zenbus node removed');
53
- }
54
- done();
55
- });
56
- }
57
-
58
- RED.nodes.registerType('zenbus-next-bus', ZenbusNextBusNode);
59
- };