anthodev/lapin-futepublic Fork
⑂ develop
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: deliver Pebble watch consultation #15

Merged@github-anthodev wants to merge feature/watch-consultation/watch/7 into develop
Merged as aabca5b95566.
25 files changed
modified packages/companion/src/index.js +273 -104
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/src/message-queue.js +57 -10
@@ -11,16 +11,42 @@ function MessageQueue(Pebble, defer, onFailure) {
1111 this._batches = [];
1212 this._active = null;
1313 this._sending = false;
14+ this._completing = false;
1415 this._deferredGeneration = null;
1516 this._generation = 0;
17+ this._nextBatchId = 1;
1618 }
1719
18-MessageQueue.prototype.enqueue = function (messages) {
20+MessageQueue.prototype.enqueue = function (messages, onComplete) {
21+ var batch;
1922 if (!Array.isArray(messages) || messages.length === 0) {
2023 throw new TypeError("A non-empty message sequence is required");
2124 }
22- this._batches.push(messages.slice());
25+ if (typeof onComplete !== "undefined" && typeof onComplete !== "function") {
26+ throw new TypeError("onComplete must be a function");
27+ }
28+ batch = {
29+ id: this._nextBatchId,
30+ messages: messages.slice(),
31+ onComplete: typeof onComplete === "function" ? onComplete : null
32+ };
33+ this._nextBatchId += 1;
34+ this._batches.push(batch);
2335 this._advance();
36+ return batch.id;
37+};
38+
39+MessageQueue.prototype.replacePending = function (batchId, messages) {
40+ var index;
41+ if (!Array.isArray(messages) || messages.length === 0) {
42+ throw new TypeError("A non-empty message sequence is required");
43+ }
44+ for (index = 0; index < this._batches.length; index += 1) {
45+ if (this._batches[index].id !== batchId) continue;
46+ this._batches[index].messages = messages.slice();
47+ return true;
48+ }
49+ return false;
2450 };
2551
2652 MessageQueue.prototype.clear = function () {
@@ -54,13 +80,16 @@ MessageQueue.prototype._scheduleAdvance = function (generation) {
5480
5581 MessageQueue.prototype._advance = function () {
5682 var self = this;
83+ var completed;
5784 var message;
5885 var generation;
59- if (this._sending || this._deferredGeneration !== null) return;
60- if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;
86+ if (this._sending || this._completing || this._deferredGeneration !== null) return;
87+ if (!this._active || this._active.messages.length === 0) {
88+ this._active = this._batches.shift() || null;
89+ }
6190 if (!this._active) return;
6291
63- message = this._active[0];
92+ message = this._active.messages[0];
6493 this._sending = true;
6594 generation = this._generation;
6695 try {
@@ -72,8 +101,26 @@ MessageQueue.prototype._advance = function () {
72101 }
73102 return;
74103 }
75- self._active.shift();
76- if (self._active.length === 0) self._active = null;
104+ self._active.messages.shift();
105+ if (self._active.messages.length === 0) {
106+ completed = self._active;
107+ self._active = null;
108+ if (completed.onComplete !== null) {
109+ self._completing = true;
110+ try {
111+ completed.onComplete();
112+ } catch (ignored) {
113+ // Completion metadata must not change acknowledged transport state.
114+ }
115+ self._completing = false;
116+ if (generation !== self._generation) {
117+ if (self._active !== null || self._batches.length > 0) {
118+ self._scheduleAdvance(self._generation);
119+ }
120+ return;
121+ }
122+ }
123+ }
77124 if (self._active !== null || self._batches.length > 0) {
78125 self._scheduleAdvance(generation);
79126 }
@@ -89,9 +136,9 @@ MessageQueue.prototype._advance = function () {
89136 self._onFailure("APP_MESSAGE_FAILED");
90137 });
91138 } catch (ignored) {
92- this._sending = false;
93- this.clear();
94- this._onFailure("APP_MESSAGE_FAILED");
139+ self._sending = false;
140+ self.clear();
141+ self._onFailure("APP_MESSAGE_FAILED");
95142 }
96143 };
97144
@@ -11,16 +11,42 @@ function MessageQueue(Pebble, defer, onFailure) {
11 this._batches = [];11 this._batches = [];
12 this._active = null;12 this._active = null;
13 this._sending = false;13 this._sending = false;
14+ this._completing = false;
14 this._deferredGeneration = null;15 this._deferredGeneration = null;
15 this._generation = 0;16 this._generation = 0;
17+ this._nextBatchId = 1;
16 }18 }
17 19
18-MessageQueue.prototype.enqueue = function (messages) {20+MessageQueue.prototype.enqueue = function (messages, onComplete) {
21+ var batch;
19 if (!Array.isArray(messages) || messages.length === 0) {22 if (!Array.isArray(messages) || messages.length === 0) {
20 throw new TypeError("A non-empty message sequence is required");23 throw new TypeError("A non-empty message sequence is required");
21 }24 }
22- this._batches.push(messages.slice());25+ if (typeof onComplete !== "undefined" && typeof onComplete !== "function") {
26+ throw new TypeError("onComplete must be a function");
27+ }
28+ batch = {
29+ id: this._nextBatchId,
30+ messages: messages.slice(),
31+ onComplete: typeof onComplete === "function" ? onComplete : null
32+ };
33+ this._nextBatchId += 1;
34+ this._batches.push(batch);
23 this._advance();35 this._advance();
36+ return batch.id;
37+};
38+
39+MessageQueue.prototype.replacePending = function (batchId, messages) {
40+ var index;
41+ if (!Array.isArray(messages) || messages.length === 0) {
42+ throw new TypeError("A non-empty message sequence is required");
43+ }
44+ for (index = 0; index < this._batches.length; index += 1) {
45+ if (this._batches[index].id !== batchId) continue;
46+ this._batches[index].messages = messages.slice();
47+ return true;
48+ }
49+ return false;
24 };50 };
25 51
26 MessageQueue.prototype.clear = function () {52 MessageQueue.prototype.clear = function () {
@@ -54,13 +80,16 @@ MessageQueue.prototype._scheduleAdvance = function (generation) {
54 80
55 MessageQueue.prototype._advance = function () {81 MessageQueue.prototype._advance = function () {
56 var self = this;82 var self = this;
83+ var completed;
57 var message;84 var message;
58 var generation;85 var generation;
59- if (this._sending || this._deferredGeneration !== null) return;86+ if (this._sending || this._completing || this._deferredGeneration !== null) return;
60- if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;87+ if (!this._active || this._active.messages.length === 0) {
88+ this._active = this._batches.shift() || null;
89+ }
61 if (!this._active) return;90 if (!this._active) return;
62 91
63- message = this._active[0];92+ message = this._active.messages[0];
64 this._sending = true;93 this._sending = true;
65 generation = this._generation;94 generation = this._generation;
66 try {95 try {
@@ -72,8 +101,26 @@ MessageQueue.prototype._advance = function () {
72 }101 }
73 return;102 return;
74 }103 }
75- self._active.shift();104+ self._active.messages.shift();
76- if (self._active.length === 0) self._active = null;105+ if (self._active.messages.length === 0) {
106+ completed = self._active;
107+ self._active = null;
108+ if (completed.onComplete !== null) {
109+ self._completing = true;
110+ try {
111+ completed.onComplete();
112+ } catch (ignored) {
113+ // Completion metadata must not change acknowledged transport state.
114+ }
115+ self._completing = false;
116+ if (generation !== self._generation) {
117+ if (self._active !== null || self._batches.length > 0) {
118+ self._scheduleAdvance(self._generation);
119+ }
120+ return;
121+ }
122+ }
123+ }
77 if (self._active !== null || self._batches.length > 0) {124 if (self._active !== null || self._batches.length > 0) {
78 self._scheduleAdvance(generation);125 self._scheduleAdvance(generation);
79 }126 }
@@ -89,9 +136,9 @@ MessageQueue.prototype._advance = function () {
89 self._onFailure("APP_MESSAGE_FAILED");136 self._onFailure("APP_MESSAGE_FAILED");
90 });137 });
91 } catch (ignored) {138 } catch (ignored) {
92- this._sending = false;139+ self._sending = false;
93- this.clear();140+ self.clear();
94- this._onFailure("APP_MESSAGE_FAILED");141+ self._onFailure("APP_MESSAGE_FAILED");
95 }142 }
96 };143 };
97 144
modified packages/companion/test/companion.test.js +547 -120
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/companion/test/protocol.test.js +41 -6
@@ -124,31 +124,38 @@ test("configuration encoder requires a normalized contextual language", function
124124 );
125125 });
126126
127-test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {
127+test("callback queue completes batches only after their final ACK and preserves FIFO deferral", function () {
128128 var Pebble = new fakes.FakePebble(false);
129129 var defer = fakes.createDefer(false);
130130 var failures = [];
131+ var completions = [];
131132 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132133
133- queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);
134- queue.enqueue([{ id: "batch-b-1" }]);
134+ queue.enqueue(
135+ [{ id: "batch-a-1" }, { id: "batch-a-2" }],
136+ function () { completions.push("a"); }
137+ );
138+ queue.enqueue([{ id: "batch-b-1" }], function () { completions.push("b"); });
135139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
136140 assert.equal(Pebble.maxInFlight, 1);
137141
138142 Pebble.ack();
143+ assert.deepEqual(completions, []);
139144 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
140145 assert.equal(defer.pending.length, 1);
141- queue.enqueue([{ id: "batch-c-1" }]);
146+ queue.enqueue([{ id: "batch-c-1" }], function () { completions.push("c"); });
142147 assert.equal(defer.pending.length, 1);
143148
144149 defer.runNext();
145150 Pebble.ack();
151+ assert.deepEqual(completions, ["a"]);
146152 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
147153 "batch-a-1",
148154 "batch-a-2"
149155 ]);
150156 defer.runNext();
151157 Pebble.ack();
158+ assert.deepEqual(completions, ["a", "b"]);
152159 defer.runNext();
153160 Pebble.ack();
154161
@@ -158,6 +165,7 @@ test("callback queue yields after each ACK and drains whole batches in FIFO orde
158165 "batch-b-1",
159166 "batch-c-1"
160167 ]);
168+ assert.deepEqual(completions, ["a", "b", "c"]);
161169 assert.equal(defer.pending.length, 0);
162170 assert.equal(queue.isSending(), false);
163171 assert.deepEqual(failures, []);
@@ -205,16 +213,43 @@ test("a stale failure cannot clear or fail a newer queue generation", function (
205213 assert.deepEqual(failures, []);
206214 });
207215
208-test("failed AppMessage is dropped without retry or sequence advancement", function () {
216+test("failed AppMessage drops the batch without retry or completion", function () {
209217 var Pebble = new fakes.FakePebble(false);
210218 var defer = fakes.createDefer(false);
211219 var failures = [];
220+ var completions = [];
212221 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
213222
214- queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);
223+ queue.enqueue(
224+ [{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }],
225+ function () { completions.push("complete"); }
226+ );
215227 Pebble.fail();
216228 assert.equal(Pebble.sent.length, 1);
217229 assert.equal(defer.pending.length, 0);
218230 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
231+ assert.deepEqual(completions, []);
232+ assert.equal(queue.isSending(), false);
233+});
234+
235+test("synchronous AppMessage failure drops the batch and reports one failure", function () {
236+ var Pebble = new fakes.FakePebble(false);
237+ var defer = fakes.createDefer(false);
238+ var failures = [];
239+ var completions = [];
240+ var queue;
241+ Pebble.sendAppMessage = function (message) {
242+ this.sent.push(message);
243+ throw new Error("synchronous send failure");
244+ };
245+ queue = new companion.MessageQueue(Pebble, defer, function (code) {
246+ failures.push(code);
247+ });
248+
249+ queue.enqueue([{ id: "throws" }], function () { completions.push("complete"); });
250+ assert.deepEqual(Pebble.sent, [{ id: "throws" }]);
251+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
252+ assert.deepEqual(completions, []);
219253 assert.equal(queue.isSending(), false);
254+ assert.equal(defer.pending.length, 0);
220255 });
@@ -124,31 +124,38 @@ test("configuration encoder requires a normalized contextual language", function
124 );124 );
125 });125 });
126 126
127-test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {127+test("callback queue completes batches only after their final ACK and preserves FIFO deferral", function () {
128 var Pebble = new fakes.FakePebble(false);128 var Pebble = new fakes.FakePebble(false);
129 var defer = fakes.createDefer(false);129 var defer = fakes.createDefer(false);
130 var failures = [];130 var failures = [];
131+ var completions = [];
131 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });132 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132 133
133- queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);134+ queue.enqueue(
134- queue.enqueue([{ id: "batch-b-1" }]);135+ [{ id: "batch-a-1" }, { id: "batch-a-2" }],
136+ function () { completions.push("a"); }
137+ );
138+ queue.enqueue([{ id: "batch-b-1" }], function () { completions.push("b"); });
135 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
136 assert.equal(Pebble.maxInFlight, 1);140 assert.equal(Pebble.maxInFlight, 1);
137 141
138 Pebble.ack();142 Pebble.ack();
143+ assert.deepEqual(completions, []);
139 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);144 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
140 assert.equal(defer.pending.length, 1);145 assert.equal(defer.pending.length, 1);
141- queue.enqueue([{ id: "batch-c-1" }]);146+ queue.enqueue([{ id: "batch-c-1" }], function () { completions.push("c"); });
142 assert.equal(defer.pending.length, 1);147 assert.equal(defer.pending.length, 1);
143 148
144 defer.runNext();149 defer.runNext();
145 Pebble.ack();150 Pebble.ack();
151+ assert.deepEqual(completions, ["a"]);
146 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [152 assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
147 "batch-a-1",153 "batch-a-1",
148 "batch-a-2"154 "batch-a-2"
149 ]);155 ]);
150 defer.runNext();156 defer.runNext();
151 Pebble.ack();157 Pebble.ack();
158+ assert.deepEqual(completions, ["a", "b"]);
152 defer.runNext();159 defer.runNext();
153 Pebble.ack();160 Pebble.ack();
154 161
@@ -158,6 +165,7 @@ test("callback queue yields after each ACK and drains whole batches in FIFO orde
158 "batch-b-1",165 "batch-b-1",
159 "batch-c-1"166 "batch-c-1"
160 ]);167 ]);
168+ assert.deepEqual(completions, ["a", "b", "c"]);
161 assert.equal(defer.pending.length, 0);169 assert.equal(defer.pending.length, 0);
162 assert.equal(queue.isSending(), false);170 assert.equal(queue.isSending(), false);
163 assert.deepEqual(failures, []);171 assert.deepEqual(failures, []);
@@ -205,16 +213,43 @@ test("a stale failure cannot clear or fail a newer queue generation", function (
205 assert.deepEqual(failures, []);213 assert.deepEqual(failures, []);
206 });214 });
207 215
208-test("failed AppMessage is dropped without retry or sequence advancement", function () {216+test("failed AppMessage drops the batch without retry or completion", function () {
209 var Pebble = new fakes.FakePebble(false);217 var Pebble = new fakes.FakePebble(false);
210 var defer = fakes.createDefer(false);218 var defer = fakes.createDefer(false);
211 var failures = [];219 var failures = [];
220+ var completions = [];
212 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });221 var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
213 222
214- queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);223+ queue.enqueue(
224+ [{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }],
225+ function () { completions.push("complete"); }
226+ );
215 Pebble.fail();227 Pebble.fail();
216 assert.equal(Pebble.sent.length, 1);228 assert.equal(Pebble.sent.length, 1);
217 assert.equal(defer.pending.length, 0);229 assert.equal(defer.pending.length, 0);
218 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);230 assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
231+ assert.deepEqual(completions, []);
232+ assert.equal(queue.isSending(), false);
233+});
234+
235+test("synchronous AppMessage failure drops the batch and reports one failure", function () {
236+ var Pebble = new fakes.FakePebble(false);
237+ var defer = fakes.createDefer(false);
238+ var failures = [];
239+ var completions = [];
240+ var queue;
241+ Pebble.sendAppMessage = function (message) {
242+ this.sent.push(message);
243+ throw new Error("synchronous send failure");
244+ };
245+ queue = new companion.MessageQueue(Pebble, defer, function (code) {
246+ failures.push(code);
247+ });
248+
249+ queue.enqueue([{ id: "throws" }], function () { completions.push("complete"); });
250+ assert.deepEqual(Pebble.sent, [{ id: "throws" }]);
251+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
252+ assert.deepEqual(completions, []);
219 assert.equal(queue.isSending(), false);253 assert.equal(queue.isSending(), false);
254+ assert.equal(defer.pending.length, 0);
220 });255 });
modified packages/watch/src/c/mdbl.c +3 -3
@@ -1,8 +1,8 @@
11 #include <pebble.h>
22
3-#define XS_STACK_BYTES (4U * 1024U)
4-#define XS_SLOT_BYTES (32U * 1024U)
5-#define XS_CHUNK_BYTES (20U * 1024U)
3+#define XS_STACK_BYTES (7U * 512U)
4+#define XS_SLOT_BYTES (77U * 512U)
5+#define XS_CHUNK_BYTES (14U * 1024U)
66 #define XS_ARENA_TOTAL_BYTES \
77 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
88 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)
@@ -1,8 +1,8 @@
1 #include <pebble.h>1 #include <pebble.h>
2 2
3-#define XS_STACK_BYTES (4U * 1024U)3+#define XS_STACK_BYTES (7U * 512U)
4-#define XS_SLOT_BYTES (32U * 1024U)4+#define XS_SLOT_BYTES (77U * 512U)
5-#define XS_CHUNK_BYTES (20U * 1024U)5+#define XS_CHUNK_BYTES (14U * 1024U)
6 #define XS_ARENA_TOTAL_BYTES \6 #define XS_ARENA_TOTAL_BYTES \
7 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)7 (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
8 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)8 #define XS_ARENA_BUDGET_BYTES (56U * 1024U)
modified packages/watch/src/embeddedjs/contracts.js +30 -60
@@ -1,5 +1,8 @@
11 export const SCHEMA_VERSION = 1;
22
3+export const APP_MESSAGE_INBOX_BYTES = 768;
4+export const APP_MESSAGE_OUTBOX_BYTES = 192;
5+
36 export const LIMITS = Object.freeze({
47 idUtf8Bytes: 64,
58 labelUtf8Bytes: 96,
@@ -55,67 +58,34 @@ export const MESSAGE_TYPE = Object.freeze({
5558 ERROR: 8
5659 });
5760
58-export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59- "SCHEMA_VERSION",
60- "MESSAGE_TYPE",
61- "REQUEST_ID",
62- "FAVORITE_ID",
63- "SERVICE_ID",
64- "DISPLAY_NAME",
65- "STOP_LABEL",
66- "LINE_LABEL",
67- "DESTINATION_LABEL",
68- "SORT_ORDER",
69- "KEY_STATUS",
70- "ITEM_COUNT",
71- "ITEM_INDEX",
72- "FETCHED_AT",
73- "SOURCE_UPDATED_AT",
74- "FRESHNESS",
75- "EXPECTED_AT",
76- "AIMED_AT",
77- "MINUTES",
78- "DEPARTURE_STATUS",
79- "NEXT_INTERVAL_MINUTES",
80- "ERROR_CODE",
81- "OCCURRED_AT",
82- "RETRY_AFTER_SECONDS",
83- "REQUEST_TRIGGER"
84-]);
85-
86-export const APP_MESSAGE_KEY = Object.freeze({
87- SCHEMA_VERSION: 0,
88- MESSAGE_TYPE: 1,
89- REQUEST_ID: 2,
90- FAVORITE_ID: 3,
91- SERVICE_ID: 4,
92- DISPLAY_NAME: 5,
93- STOP_LABEL: 6,
94- LINE_LABEL: 7,
95- DESTINATION_LABEL: 8,
96- SORT_ORDER: 9,
97- KEY_STATUS: 10,
98- ITEM_COUNT: 11,
99- ITEM_INDEX: 12,
100- FETCHED_AT: 13,
101- SOURCE_UPDATED_AT: 14,
102- FRESHNESS: 15,
103- EXPECTED_AT: 16,
104- AIMED_AT: 17,
105- MINUTES: 18,
106- DEPARTURE_STATUS: 19,
107- NEXT_INTERVAL_MINUTES: 20,
108- ERROR_CODE: 21,
109- OCCURRED_AT: 22,
110- RETRY_AFTER_SECONDS: 23,
111- REQUEST_TRIGGER: 24
112-});
11361 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114-export const APP_MESSAGE_KEY_MAP = new Map();
115-for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {
116- const alias = APP_MESSAGE_KEY_ORDER[index];
117- APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);
118-}
62+export const APP_MESSAGE_KEY_MAP = new Map([
63+ ["SCHEMA_VERSION", 0],
64+ ["MESSAGE_TYPE", 1],
65+ ["REQUEST_ID", 2],
66+ ["FAVORITE_ID", 3],
67+ ["SERVICE_ID", 4],
68+ ["DISPLAY_NAME", 5],
69+ ["STOP_LABEL", 6],
70+ ["LINE_LABEL", 7],
71+ ["DESTINATION_LABEL", 8],
72+ ["SORT_ORDER", 9],
73+ ["KEY_STATUS", 10],
74+ ["ITEM_COUNT", 11],
75+ ["ITEM_INDEX", 12],
76+ ["FETCHED_AT", 13],
77+ ["SOURCE_UPDATED_AT", 14],
78+ ["FRESHNESS", 15],
79+ ["EXPECTED_AT", 16],
80+ ["AIMED_AT", 17],
81+ ["MINUTES", 18],
82+ ["DEPARTURE_STATUS", 19],
83+ ["NEXT_INTERVAL_MINUTES", 20],
84+ ["ERROR_CODE", 21],
85+ ["OCCURRED_AT", 22],
86+ ["RETRY_AFTER_SECONDS", 23],
87+ ["REQUEST_TRIGGER", 24]
88+]);
11989
12090 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
12191
@@ -1,5 +1,8 @@
1 export const SCHEMA_VERSION = 1;1 export const SCHEMA_VERSION = 1;
2 2
3+export const APP_MESSAGE_INBOX_BYTES = 768;
4+export const APP_MESSAGE_OUTBOX_BYTES = 192;
5+
3 export const LIMITS = Object.freeze({6 export const LIMITS = Object.freeze({
4 idUtf8Bytes: 64,7 idUtf8Bytes: 64,
5 labelUtf8Bytes: 96,8 labelUtf8Bytes: 96,
@@ -55,67 +58,34 @@ export const MESSAGE_TYPE = Object.freeze({
55 ERROR: 858 ERROR: 8
56 });59 });
57 60
58-export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59- "SCHEMA_VERSION",
60- "MESSAGE_TYPE",
61- "REQUEST_ID",
62- "FAVORITE_ID",
63- "SERVICE_ID",
64- "DISPLAY_NAME",
65- "STOP_LABEL",
66- "LINE_LABEL",
67- "DESTINATION_LABEL",
68- "SORT_ORDER",
69- "KEY_STATUS",
70- "ITEM_COUNT",
71- "ITEM_INDEX",
72- "FETCHED_AT",
73- "SOURCE_UPDATED_AT",
74- "FRESHNESS",
75- "EXPECTED_AT",
76- "AIMED_AT",
77- "MINUTES",
78- "DEPARTURE_STATUS",
79- "NEXT_INTERVAL_MINUTES",
80- "ERROR_CODE",
81- "OCCURRED_AT",
82- "RETRY_AFTER_SECONDS",
83- "REQUEST_TRIGGER"
84-]);
85-
86-export const APP_MESSAGE_KEY = Object.freeze({
87- SCHEMA_VERSION: 0,
88- MESSAGE_TYPE: 1,
89- REQUEST_ID: 2,
90- FAVORITE_ID: 3,
91- SERVICE_ID: 4,
92- DISPLAY_NAME: 5,
93- STOP_LABEL: 6,
94- LINE_LABEL: 7,
95- DESTINATION_LABEL: 8,
96- SORT_ORDER: 9,
97- KEY_STATUS: 10,
98- ITEM_COUNT: 11,
99- ITEM_INDEX: 12,
100- FETCHED_AT: 13,
101- SOURCE_UPDATED_AT: 14,
102- FRESHNESS: 15,
103- EXPECTED_AT: 16,
104- AIMED_AT: 17,
105- MINUTES: 18,
106- DEPARTURE_STATUS: 19,
107- NEXT_INTERVAL_MINUTES: 20,
108- ERROR_CODE: 21,
109- OCCURRED_AT: 22,
110- RETRY_AFTER_SECONDS: 23,
111- REQUEST_TRIGGER: 24
112-});
113 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.61 // SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114-export const APP_MESSAGE_KEY_MAP = new Map();62+export const APP_MESSAGE_KEY_MAP = new Map([
115-for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {63+ ["SCHEMA_VERSION", 0],
116- const alias = APP_MESSAGE_KEY_ORDER[index];64+ ["MESSAGE_TYPE", 1],
117- APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);65+ ["REQUEST_ID", 2],
118-}66+ ["FAVORITE_ID", 3],
67+ ["SERVICE_ID", 4],
68+ ["DISPLAY_NAME", 5],
69+ ["STOP_LABEL", 6],
70+ ["LINE_LABEL", 7],
71+ ["DESTINATION_LABEL", 8],
72+ ["SORT_ORDER", 9],
73+ ["KEY_STATUS", 10],
74+ ["ITEM_COUNT", 11],
75+ ["ITEM_INDEX", 12],
76+ ["FETCHED_AT", 13],
77+ ["SOURCE_UPDATED_AT", 14],
78+ ["FRESHNESS", 15],
79+ ["EXPECTED_AT", 16],
80+ ["AIMED_AT", 17],
81+ ["MINUTES", 18],
82+ ["DEPARTURE_STATUS", 19],
83+ ["NEXT_INTERVAL_MINUTES", 20],
84+ ["ERROR_CODE", 21],
85+ ["OCCURRED_AT", 22],
86+ ["RETRY_AFTER_SECONDS", 23],
87+ ["REQUEST_TRIGGER", 24]
88+]);
119 89
120 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });90 export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
121 91
modified packages/watch/src/embeddedjs/controller.js +149 -46
@@ -1,4 +1,5 @@
11 import { QUEUE_STATE } from "./message-queue.js";
2+import { LIMITS, REQUEST_TRIGGER } from "./contracts.js";
23 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
34 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
45
@@ -15,6 +16,9 @@ export function createController(options) {
1516 || typeof options.clock.now !== "function"
1617 || !options.queue
1718 || typeof options.queue.enqueue !== "function"
19+ || !options.scheduler
20+ || typeof options.scheduler.set !== "function"
21+ || typeof options.scheduler.clear !== "function"
1822 || !options.model
1923 || typeof options.model.snapshot !== "function"
2024 || !options.view
@@ -24,95 +28,191 @@ export function createController(options) {
2428
2529 const clock = options.clock;
2630 const queue = options.queue;
31+ const scheduler = options.scheduler;
2732 const model = options.model;
2833 const storage = options.storage || null;
2934 const view = options.view;
3035 const receiver = new ProtocolReceiver();
31- let committedConfiguration = null;
36+ const requestPrefix = "consult-"
37+ + Math.max(0, Math.floor(clock.now() / 1000)).toString(36)
38+ + "-";
39+ let committedProtocolState = null;
3240 let sequence = 0;
33- let restoredRequestPending = false;
41+ let selectionGeneration = 0;
42+ let selectionTimer = null;
43+ let transportFailureGeneration = 0;
44+ let configurationGeneration = 0;
45+ let configurationTimer = null;
46+ let appOpenRequested = false;
3447 let started = false;
3548 let closed = false;
49+ let active = true;
3650
3751 function render() {
38- view.render(model.snapshot());
52+ const snapshot = model.snapshot(clock.now(), false);
53+ view.render(snapshot);
54+ return snapshot;
3955 }
4056
41- function nextRequestId() {
42- sequence += 1;
43- if (sequence > 1679615) sequence = 1;
44- const seconds = Math.max(0, Math.floor(clock.now() / 1000));
45- return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);
57+ function invalidateSelectionTimer() {
58+ selectionGeneration += 1;
59+ if (selectionTimer !== null) scheduler.clear(selectionTimer);
60+ selectionTimer = null;
61+ }
62+
63+ function invalidateConfigurationTimer() {
64+ configurationGeneration += 1;
65+ if (configurationTimer !== null) scheduler.clear(configurationTimer);
66+ configurationTimer = null;
67+ }
68+
69+ function failRequest() {
70+ receiver.cancelExpectedResponse();
71+ model.cancelRequest();
72+ model.markSendFailure();
73+ render();
4674 }
4775
48- function requestFixture() {
76+ function requestActiveFavorite(trigger) {
4977 if (closed) return false;
50- const request = model.beginFixtureRequest(nextRequestId());
51- if (!request) return false;
52- if (!receiver.expectResponse(request.requestId, request.favoriteId)
53- || !queue.enqueue(encodeRequest(request))) {
54- receiver.cancelExpectedResponse();
55- model.markSendFailure();
78+ sequence += 1;
79+ const request = model.beginRequest(requestPrefix + sequence, trigger);
80+ if (!request) {
81+ sequence -= 1;
82+ return false;
83+ }
84+ if (trigger === REQUEST_TRIGGER.APP_OPEN) appOpenRequested = true;
85+
86+ if (!receiver.expectResponse(
87+ request.requestId,
88+ request.favoriteId,
89+ requestPrefix,
90+ sequence
91+ )) {
92+ failRequest();
93+ return false;
94+ }
95+
96+ const failureGeneration = transportFailureGeneration;
97+ const enqueued = queue.enqueue(encodeRequest(request));
98+ if (transportFailureGeneration !== failureGeneration) return false;
99+ if (!enqueued) {
100+ failRequest();
101+ return false;
102+ }
103+ render();
104+ return true;
105+ }
106+
107+ function armSelectionRequest(favoriteId) {
108+ invalidateSelectionTimer();
109+ const generation = selectionGeneration;
110+ selectionTimer = scheduler.set(function () {
111+ if (generation !== selectionGeneration) return;
112+ selectionTimer = null;
113+ selectionGeneration += 1;
114+ if (closed || !active) return;
115+ const favorite = model.snapshot(clock.now()).activeFavorite;
116+ if (!favorite || favorite.id !== favoriteId) return;
117+ requestActiveFavorite(REQUEST_TRIGGER.FAVORITE_SELECTION);
118+ }, LIMITS.favoriteSettleMs);
119+ }
120+
121+ function applyConfiguration(snapshot) {
122+ if (storage && !saveWatchConfiguration(storage, snapshot.configuration)) {
123+ receiver.restoreState(committedProtocolState, false);
124+ model.commitProtocol(receiver.borrowState(), "CONFIG_COMMITTED");
56125 render();
57126 return false;
58127 }
128+ model.commitProtocol(snapshot, "CONFIG_COMMITTED");
129+ committedProtocolState = snapshot;
59130 render();
131+ if (!appOpenRequested) requestActiveFavorite(REQUEST_TRIGGER.APP_OPEN);
60132 return true;
61133 }
62134
135+ function deferConfiguration(snapshot) {
136+ invalidateConfigurationTimer();
137+ const generation = configurationGeneration;
138+ configurationTimer = scheduler.set(function () {
139+ if (closed || generation !== configurationGeneration) return;
140+ configurationTimer = null;
141+ configurationGeneration += 1;
142+ applyConfiguration(snapshot);
143+ }, 0);
144+ }
145+
63146 const controller = {
64147 start() {
65148 if (closed || started) return controller;
66149 started = true;
67150 const configuration = storage ? loadWatchConfiguration(storage) : null;
68151 if (configuration) {
69- committedConfiguration = configuration;
70- receiver.restoreConfiguration(configuration);
71- model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");
72- restoredRequestPending = true;
73- render();
74- } else {
75- render();
152+ receiver.restoreConfiguration(configuration, false);
153+ committedProtocolState = receiver.borrowState();
154+ model.commitProtocol(committedProtocolState, "CONFIG_RESTORED");
76155 }
156+ render();
77157 return controller;
78158 },
79159
80160 onReadable(message) {
81161 if (closed) return RECEIVE_RESULT.REJECTED;
82162 const received = receiver.receive(message);
163+ if (received === RECEIVE_RESULT.STAGED
164+ && receiver.configStage
165+ && receiver.configStage.favorites.length === 0
166+ && storage
167+ && typeof view.releasePresentation === "function") {
168+ view.releasePresentation();
169+ }
83170 const event = eventName(received);
84- if (event === "CONFIG_COMMITTED") restoredRequestPending = false;
85- if (event !== null) {
86- let snapshot = receiver.snapshot();
87- if (event === "CONFIG_COMMITTED" && storage) {
88- if (!saveWatchConfiguration(storage, snapshot.configuration)) {
89- receiver.restoreConfiguration(committedConfiguration);
90- snapshot = receiver.snapshot();
91- const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92- render();
93- if (effect.requestFixture) requestFixture();
94- return RECEIVE_RESULT.REJECTED;
95- }
96- committedConfiguration = snapshot.configuration;
97- }
98- const effect = model.commitProtocol(snapshot, event);
99- render();
100- if (effect.requestFixture) requestFixture();
171+ if (event === null) return received;
172+
173+ const snapshot = receiver.borrowState();
174+ if (event === "CONFIG_COMMITTED") {
175+ if (storage) deferConfiguration(snapshot);
176+ else applyConfiguration(snapshot);
177+ return received;
101178 }
179+
180+ model.commitProtocol(snapshot, event);
181+ committedProtocolState = snapshot;
182+ render();
102183 return received;
103184 },
104185
105186 onQueueState(state) {
106- if (closed || !state) return;
107- if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {
108- restoredRequestPending = false;
109- requestFixture();
187+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
188+ transportFailureGeneration += 1;
189+ failRequest();
190+ },
191+
192+ onButton(type) {
193+ if (closed || !active) return;
194+ if (type === "select") {
195+ invalidateSelectionTimer();
196+ requestActiveFavorite(REQUEST_TRIGGER.MANUAL_SELECT);
110197 return;
111198 }
112- if (state.type !== QUEUE_STATE.FAILED) return;
199+
200+ const delta = type === "up" ? -1 : type === "down" ? 1 : 0;
201+ if (delta === 0 || !model.moveSelection(delta)) return;
113202 receiver.cancelExpectedResponse();
114- model.markSendFailure();
115- render();
203+ model.cancelRequest();
204+ const snapshot = render();
205+ if (snapshot.activeFavorite) armSelectionRequest(snapshot.activeFavorite.id);
206+ },
207+
208+ onMinuteChange() {
209+ if (!closed && active) render();
210+ },
211+
212+ setActive(nextActive) {
213+ if (closed) return;
214+ active = nextActive === true;
215+ if (!active) invalidateSelectionTimer();
116216 },
117217
118218 redraw() {
@@ -122,7 +222,10 @@ export function createController(options) {
122222 close() {
123223 if (closed) return;
124224 closed = true;
225+ invalidateSelectionTimer();
226+ invalidateConfigurationTimer();
125227 receiver.cancelExpectedResponse();
228+ model.cancelRequest();
126229 receiver.discardStaging();
127230 queue.close();
128231 if (typeof view.close === "function") view.close();
@@ -1,4 +1,5 @@
1 import { QUEUE_STATE } from "./message-queue.js";1 import { QUEUE_STATE } from "./message-queue.js";
2+import { LIMITS, REQUEST_TRIGGER } from "./contracts.js";
2 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";3 import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";4 import { loadWatchConfiguration, saveWatchConfiguration } from "./storage.js";
4 5
@@ -15,6 +16,9 @@ export function createController(options) {
15 || typeof options.clock.now !== "function"16 || typeof options.clock.now !== "function"
16 || !options.queue17 || !options.queue
17 || typeof options.queue.enqueue !== "function"18 || typeof options.queue.enqueue !== "function"
19+ || !options.scheduler
20+ || typeof options.scheduler.set !== "function"
21+ || typeof options.scheduler.clear !== "function"
18 || !options.model22 || !options.model
19 || typeof options.model.snapshot !== "function"23 || typeof options.model.snapshot !== "function"
20 || !options.view24 || !options.view
@@ -24,95 +28,191 @@ export function createController(options) {
24 28
25 const clock = options.clock;29 const clock = options.clock;
26 const queue = options.queue;30 const queue = options.queue;
31+ const scheduler = options.scheduler;
27 const model = options.model;32 const model = options.model;
28 const storage = options.storage || null;33 const storage = options.storage || null;
29 const view = options.view;34 const view = options.view;
30 const receiver = new ProtocolReceiver();35 const receiver = new ProtocolReceiver();
31- let committedConfiguration = null;36+ const requestPrefix = "consult-"
37+ + Math.max(0, Math.floor(clock.now() / 1000)).toString(36)
38+ + "-";
39+ let committedProtocolState = null;
32 let sequence = 0;40 let sequence = 0;
33- let restoredRequestPending = false;41+ let selectionGeneration = 0;
42+ let selectionTimer = null;
43+ let transportFailureGeneration = 0;
44+ let configurationGeneration = 0;
45+ let configurationTimer = null;
46+ let appOpenRequested = false;
34 let started = false;47 let started = false;
35 let closed = false;48 let closed = false;
49+ let active = true;
36 50
37 function render() {51 function render() {
38- view.render(model.snapshot());52+ const snapshot = model.snapshot(clock.now(), false);
53+ view.render(snapshot);
54+ return snapshot;
39 }55 }
40 56
41- function nextRequestId() {57+ function invalidateSelectionTimer() {
42- sequence += 1;58+ selectionGeneration += 1;
43- if (sequence > 1679615) sequence = 1;59+ if (selectionTimer !== null) scheduler.clear(selectionTimer);
44- const seconds = Math.max(0, Math.floor(clock.now() / 1000));60+ selectionTimer = null;
45- return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);61+ }
62+
63+ function invalidateConfigurationTimer() {
64+ configurationGeneration += 1;
65+ if (configurationTimer !== null) scheduler.clear(configurationTimer);
66+ configurationTimer = null;
67+ }
68+
69+ function failRequest() {
70+ receiver.cancelExpectedResponse();
71+ model.cancelRequest();
72+ model.markSendFailure();
73+ render();
46 }74 }
47 75
48- function requestFixture() {76+ function requestActiveFavorite(trigger) {
49 if (closed) return false;77 if (closed) return false;
50- const request = model.beginFixtureRequest(nextRequestId());78+ sequence += 1;
51- if (!request) return false;79+ const request = model.beginRequest(requestPrefix + sequence, trigger);
52- if (!receiver.expectResponse(request.requestId, request.favoriteId)80+ if (!request) {
53- || !queue.enqueue(encodeRequest(request))) {81+ sequence -= 1;
54- receiver.cancelExpectedResponse();82+ return false;
55- model.markSendFailure();83+ }
84+ if (trigger === REQUEST_TRIGGER.APP_OPEN) appOpenRequested = true;
85+
86+ if (!receiver.expectResponse(
87+ request.requestId,
88+ request.favoriteId,
89+ requestPrefix,
90+ sequence
91+ )) {
92+ failRequest();
93+ return false;
94+ }
95+
96+ const failureGeneration = transportFailureGeneration;
97+ const enqueued = queue.enqueue(encodeRequest(request));
98+ if (transportFailureGeneration !== failureGeneration) return false;
99+ if (!enqueued) {
100+ failRequest();
101+ return false;
102+ }
103+ render();
104+ return true;
105+ }
106+
107+ function armSelectionRequest(favoriteId) {
108+ invalidateSelectionTimer();
109+ const generation = selectionGeneration;
110+ selectionTimer = scheduler.set(function () {
111+ if (generation !== selectionGeneration) return;
112+ selectionTimer = null;
113+ selectionGeneration += 1;
114+ if (closed || !active) return;
115+ const favorite = model.snapshot(clock.now()).activeFavorite;
116+ if (!favorite || favorite.id !== favoriteId) return;
117+ requestActiveFavorite(REQUEST_TRIGGER.FAVORITE_SELECTION);
118+ }, LIMITS.favoriteSettleMs);
119+ }
120+
121+ function applyConfiguration(snapshot) {
122+ if (storage && !saveWatchConfiguration(storage, snapshot.configuration)) {
123+ receiver.restoreState(committedProtocolState, false);
124+ model.commitProtocol(receiver.borrowState(), "CONFIG_COMMITTED");
56 render();125 render();
57 return false;126 return false;
58 }127 }
128+ model.commitProtocol(snapshot, "CONFIG_COMMITTED");
129+ committedProtocolState = snapshot;
59 render();130 render();
131+ if (!appOpenRequested) requestActiveFavorite(REQUEST_TRIGGER.APP_OPEN);
60 return true;132 return true;
61 }133 }
62 134
135+ function deferConfiguration(snapshot) {
136+ invalidateConfigurationTimer();
137+ const generation = configurationGeneration;
138+ configurationTimer = scheduler.set(function () {
139+ if (closed || generation !== configurationGeneration) return;
140+ configurationTimer = null;
141+ configurationGeneration += 1;
142+ applyConfiguration(snapshot);
143+ }, 0);
144+ }
145+
63 const controller = {146 const controller = {
64 start() {147 start() {
65 if (closed || started) return controller;148 if (closed || started) return controller;
66 started = true;149 started = true;
67 const configuration = storage ? loadWatchConfiguration(storage) : null;150 const configuration = storage ? loadWatchConfiguration(storage) : null;
68 if (configuration) {151 if (configuration) {
69- committedConfiguration = configuration;152+ receiver.restoreConfiguration(configuration, false);
70- receiver.restoreConfiguration(configuration);153+ committedProtocolState = receiver.borrowState();
71- model.commitProtocol(receiver.snapshot(), "CONFIG_RESTORED");154+ model.commitProtocol(committedProtocolState, "CONFIG_RESTORED");
72- restoredRequestPending = true;
73- render();
74- } else {
75- render();
76 }155 }
156+ render();
77 return controller;157 return controller;
78 },158 },
79 159
80 onReadable(message) {160 onReadable(message) {
81 if (closed) return RECEIVE_RESULT.REJECTED;161 if (closed) return RECEIVE_RESULT.REJECTED;
82 const received = receiver.receive(message);162 const received = receiver.receive(message);
163+ if (received === RECEIVE_RESULT.STAGED
164+ && receiver.configStage
165+ && receiver.configStage.favorites.length === 0
166+ && storage
167+ && typeof view.releasePresentation === "function") {
168+ view.releasePresentation();
169+ }
83 const event = eventName(received);170 const event = eventName(received);
84- if (event === "CONFIG_COMMITTED") restoredRequestPending = false;171+ if (event === null) return received;
85- if (event !== null) {172+
86- let snapshot = receiver.snapshot();173+ const snapshot = receiver.borrowState();
87- if (event === "CONFIG_COMMITTED" && storage) {174+ if (event === "CONFIG_COMMITTED") {
88- if (!saveWatchConfiguration(storage, snapshot.configuration)) {175+ if (storage) deferConfiguration(snapshot);
89- receiver.restoreConfiguration(committedConfiguration);176+ else applyConfiguration(snapshot);
90- snapshot = receiver.snapshot();177+ return received;
91- const effect = model.commitProtocol(snapshot, "CONFIG_COMMITTED");
92- render();
93- if (effect.requestFixture) requestFixture();
94- return RECEIVE_RESULT.REJECTED;
95- }
96- committedConfiguration = snapshot.configuration;
97- }
98- const effect = model.commitProtocol(snapshot, event);
99- render();
100- if (effect.requestFixture) requestFixture();
101 }178 }
179+
180+ model.commitProtocol(snapshot, event);
181+ committedProtocolState = snapshot;
182+ render();
102 return received;183 return received;
103 },184 },
104 185
105 onQueueState(state) {186 onQueueState(state) {
106- if (closed || !state) return;187+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
107- if (state.type === QUEUE_STATE.WRITABLE && restoredRequestPending) {188+ transportFailureGeneration += 1;
108- restoredRequestPending = false;189+ failRequest();
109- requestFixture();190+ },
191+
192+ onButton(type) {
193+ if (closed || !active) return;
194+ if (type === "select") {
195+ invalidateSelectionTimer();
196+ requestActiveFavorite(REQUEST_TRIGGER.MANUAL_SELECT);
110 return;197 return;
111 }198 }
112- if (state.type !== QUEUE_STATE.FAILED) return;199+
200+ const delta = type === "up" ? -1 : type === "down" ? 1 : 0;
201+ if (delta === 0 || !model.moveSelection(delta)) return;
113 receiver.cancelExpectedResponse();202 receiver.cancelExpectedResponse();
114- model.markSendFailure();203+ model.cancelRequest();
115- render();204+ const snapshot = render();
205+ if (snapshot.activeFavorite) armSelectionRequest(snapshot.activeFavorite.id);
206+ },
207+
208+ onMinuteChange() {
209+ if (!closed && active) render();
210+ },
211+
212+ setActive(nextActive) {
213+ if (closed) return;
214+ active = nextActive === true;
215+ if (!active) invalidateSelectionTimer();
116 },216 },
117 217
118 redraw() {218 redraw() {
@@ -122,7 +222,10 @@ export function createController(options) {
122 close() {222 close() {
123 if (closed) return;223 if (closed) return;
124 closed = true;224 closed = true;
225+ invalidateSelectionTimer();
226+ invalidateConfigurationTimer();
125 receiver.cancelExpectedResponse();227 receiver.cancelExpectedResponse();
228+ model.cancelRequest();
126 receiver.discardStaging();229 receiver.discardStaging();
127 queue.close();230 queue.close();
128 if (typeof view.close === "function") view.close();231 if (typeof view.close === "function") view.close();
modified packages/watch/src/embeddedjs/localization.js +53 -28
@@ -2,39 +2,64 @@ import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
22
33 export const LANGUAGE = CONTRACT_LANGUAGE;
44
5-export const COPY = Object.freeze({
6- en: Object.freeze({
7- appName: "Lapin Futé",
8- synchronizing: "Synchronizing fixture…",
9- waiting: "Waiting for recorded departures…",
10- unavailable: "Fixture unavailable",
11- noDepartures: "No recorded departures",
12- recordedFixture: "Recorded foundation fixture",
13- now: "Now",
14- departed: "Departed"
15- }),
16- fr: Object.freeze({
17- appName: "Lapin Futé",
18- synchronizing: "Synchronisation du scénario…",
19- waiting: "En attente des départs enregistrés…",
20- unavailable: "Scénario indisponible",
21- noDepartures: "Aucun départ enregistré",
22- recordedFixture: "Scénario de fondation enregistré",
23- now: "À quai",
24- departed: "Parti"
25- })
26-});
27-
285 export function normalizeLanguage(tag) {
296 if (typeof tag !== "string") return LANGUAGE.EN;
30- return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR
31- ? LANGUAGE.FR
32- : LANGUAGE.EN;
7+ const french = (tag.charCodeAt(0) === 70 || tag.charCodeAt(0) === 102)
8+ && (tag.charCodeAt(1) === 82 || tag.charCodeAt(1) === 114)
9+ && (tag.length === 2 || tag[2] === "-" || tag[2] === "_");
10+ return french ? LANGUAGE.FR : LANGUAGE.EN;
3311 }
3412
3513 export function copy(language, id) {
36- const table = COPY[normalizeLanguage(language)];
37- return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";
14+ const french = normalizeLanguage(language) === LANGUAGE.FR;
15+ switch (id) {
16+ case "unconfigured":
17+ return french
18+ ? "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori"
19+ : "Open phone options\nAdd a PRIM key and favorite";
20+ case "loading":
21+ return french ? "Chargement des départs…" : "Loading departures…";
22+ case "apiKeyInvalid":
23+ return french
24+ ? "Clé PRIM refusée\nOuvrez les options téléphone"
25+ : "PRIM key rejected\nOpen phone options";
26+ case "favoriteUnavailable":
27+ return french
28+ ? "Favori indisponible\nOuvrez les options téléphone"
29+ : "Favorite unavailable\nOpen phone options";
30+ case "rateLimited":
31+ return french
32+ ? "Trop de demandes\nRéessayez plus tard"
33+ : "Too many requests\nTry again later";
34+ case "departuresUnavailable":
35+ return french
36+ ? "Départs indisponibles\nSélection pour réessayer"
37+ : "Departures unavailable\nSelect to retry";
38+ case "noDepartures":
39+ return french ? "Aucun départ" : "No departures";
40+ case "delayed":
41+ return french ? "Retardé" : "Delayed";
42+ case "cancelled":
43+ return french ? "Annulé" : "Cancelled";
44+ case "then":
45+ return french ? "puis" : "then";
46+ case "freshRealtime":
47+ return french ? "Temps réel" : "Live";
48+ case "freshScheduled":
49+ return french ? "Horaires" : "Scheduled";
50+ case "freshMixed":
51+ return french ? "Temps réel + horaires" : "Live + schedule";
52+ case "freshStale":
53+ return french ? "Périmé" : "Out of date";
54+ case "updated":
55+ return french ? "Mis à jour" : "Updated";
56+ case "now":
57+ return french ? "À quai" : "Now";
58+ case "departed":
59+ return french ? "Parti" : "Departed";
60+ default:
61+ return "";
62+ }
3863 }
3964
4065 export function formatMinutes(language, minutes) {
@@ -2,39 +2,64 @@ import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
2 2
3 export const LANGUAGE = CONTRACT_LANGUAGE;3 export const LANGUAGE = CONTRACT_LANGUAGE;
4 4
5-export const COPY = Object.freeze({
6- en: Object.freeze({
7- appName: "Lapin Futé",
8- synchronizing: "Synchronizing fixture…",
9- waiting: "Waiting for recorded departures…",
10- unavailable: "Fixture unavailable",
11- noDepartures: "No recorded departures",
12- recordedFixture: "Recorded foundation fixture",
13- now: "Now",
14- departed: "Departed"
15- }),
16- fr: Object.freeze({
17- appName: "Lapin Futé",
18- synchronizing: "Synchronisation du scénario…",
19- waiting: "En attente des départs enregistrés…",
20- unavailable: "Scénario indisponible",
21- noDepartures: "Aucun départ enregistré",
22- recordedFixture: "Scénario de fondation enregistré",
23- now: "À quai",
24- departed: "Parti"
25- })
26-});
27-
28 export function normalizeLanguage(tag) {5 export function normalizeLanguage(tag) {
29 if (typeof tag !== "string") return LANGUAGE.EN;6 if (typeof tag !== "string") return LANGUAGE.EN;
30- return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR7+ const french = (tag.charCodeAt(0) === 70 || tag.charCodeAt(0) === 102)
31- ? LANGUAGE.FR8+ && (tag.charCodeAt(1) === 82 || tag.charCodeAt(1) === 114)
32- : LANGUAGE.EN;9+ && (tag.length === 2 || tag[2] === "-" || tag[2] === "_");
10+ return french ? LANGUAGE.FR : LANGUAGE.EN;
33 }11 }
34 12
35 export function copy(language, id) {13 export function copy(language, id) {
36- const table = COPY[normalizeLanguage(language)];14+ const french = normalizeLanguage(language) === LANGUAGE.FR;
37- return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";15+ switch (id) {
16+ case "unconfigured":
17+ return french
18+ ? "Ouvrez les options téléphone\nAjoutez une clé PRIM et un favori"
19+ : "Open phone options\nAdd a PRIM key and favorite";
20+ case "loading":
21+ return french ? "Chargement des départs…" : "Loading departures…";
22+ case "apiKeyInvalid":
23+ return french
24+ ? "Clé PRIM refusée\nOuvrez les options téléphone"
25+ : "PRIM key rejected\nOpen phone options";
26+ case "favoriteUnavailable":
27+ return french
28+ ? "Favori indisponible\nOuvrez les options téléphone"
29+ : "Favorite unavailable\nOpen phone options";
30+ case "rateLimited":
31+ return french
32+ ? "Trop de demandes\nRéessayez plus tard"
33+ : "Too many requests\nTry again later";
34+ case "departuresUnavailable":
35+ return french
36+ ? "Départs indisponibles\nSélection pour réessayer"
37+ : "Departures unavailable\nSelect to retry";
38+ case "noDepartures":
39+ return french ? "Aucun départ" : "No departures";
40+ case "delayed":
41+ return french ? "Retardé" : "Delayed";
42+ case "cancelled":
43+ return french ? "Annulé" : "Cancelled";
44+ case "then":
45+ return french ? "puis" : "then";
46+ case "freshRealtime":
47+ return french ? "Temps réel" : "Live";
48+ case "freshScheduled":
49+ return french ? "Horaires" : "Scheduled";
50+ case "freshMixed":
51+ return french ? "Temps réel + horaires" : "Live + schedule";
52+ case "freshStale":
53+ return french ? "Périmé" : "Out of date";
54+ case "updated":
55+ return french ? "Mis à jour" : "Updated";
56+ case "now":
57+ return french ? "À quai" : "Now";
58+ case "departed":
59+ return french ? "Parti" : "Departed";
60+ default:
61+ return "";
62+ }
38 }63 }
39 64
40 export function formatMinutes(language, minutes) {65 export function formatMinutes(language, minutes) {
modified packages/watch/src/embeddedjs/main.js +26 -5
@@ -1,5 +1,11 @@
1+import Button from "pebble/button";
12 import Message from "pebble/message";
2-import { APP_MESSAGE_KEY_MAP } from "./contracts.js";
3+import Timer from "timer";
4+import {
5+ APP_MESSAGE_INBOX_BYTES,
6+ APP_MESSAGE_KEY_MAP,
7+ APP_MESSAGE_OUTBOX_BYTES
8+} from "./contracts.js";
39 import { createController } from "./controller.js";
410 import MessageQueue from "./message-queue.js";
511 import WatchModel from "./model.js";
@@ -9,7 +15,8 @@ function screenInfo() {
915 return {
1016 width: screen.width,
1117 height: screen.height,
12- round: screen.round === true
18+ round: screen.round === true,
19+ hour12: watch.hour12 === true
1320 };
1421 }
1522
@@ -20,6 +27,8 @@ const pendingMessages = [];
2027 const view = createWatchView(screenInfo());
2128 const message = new Message({
2229 keys: APP_MESSAGE_KEY_MAP,
30+ input: APP_MESSAGE_INBOX_BYTES,
31+ output: APP_MESSAGE_OUTBOX_BYTES,
2332 onReadable() {
2433 const incoming = this.read();
2534 if (controller) controller.onReadable(incoming);
@@ -42,7 +51,8 @@ queue = new MessageQueue(message, {
4251 });
4352
4453 controller = createController({
45- clock: { now: Date.now },
54+ clock: Date,
55+ scheduler: Timer,
4656 queue,
4757 model: new WatchModel(),
4858 storage: localStorage,
@@ -51,10 +61,21 @@ controller = createController({
5161 controller.start();
5262 if (pendingChannel === "writable") queue.writable();
5363 else if (pendingChannel === "suspended") queue.suspend();
54-pendingMessages.forEach(function (incoming) {
55- controller.onReadable(incoming);
64+for (let index = 0; index < pendingMessages.length; index += 1) {
65+ controller.onReadable(pendingMessages[index]);
66+}
67+const button = new Button({
68+ types: ["select", "up", "down"],
69+ single: true,
70+ onPush(active, type) {
71+ if (active) controller.onButton(type);
72+ }
5673 });
5774
75+watch.addEventListener("minutechange", controller.onMinuteChange);
76+
77+watch.addEventListener("willFocus", controller.setActive);
78+
5879 watch.addEventListener("resize", function () {
5980 view.resize(screenInfo());
6081 controller.redraw();
@@ -1,5 +1,11 @@
1+import Button from "pebble/button";
1 import Message from "pebble/message";2 import Message from "pebble/message";
2-import { APP_MESSAGE_KEY_MAP } from "./contracts.js";3+import Timer from "timer";
4+import {
5+ APP_MESSAGE_INBOX_BYTES,
6+ APP_MESSAGE_KEY_MAP,
7+ APP_MESSAGE_OUTBOX_BYTES
8+} from "./contracts.js";
3 import { createController } from "./controller.js";9 import { createController } from "./controller.js";
4 import MessageQueue from "./message-queue.js";10 import MessageQueue from "./message-queue.js";
5 import WatchModel from "./model.js";11 import WatchModel from "./model.js";
@@ -9,7 +15,8 @@ function screenInfo() {
9 return {15 return {
10 width: screen.width,16 width: screen.width,
11 height: screen.height,17 height: screen.height,
12- round: screen.round === true18+ round: screen.round === true,
19+ hour12: watch.hour12 === true
13 };20 };
14 }21 }
15 22
@@ -20,6 +27,8 @@ const pendingMessages = [];
20 const view = createWatchView(screenInfo());27 const view = createWatchView(screenInfo());
21 const message = new Message({28 const message = new Message({
22 keys: APP_MESSAGE_KEY_MAP,29 keys: APP_MESSAGE_KEY_MAP,
30+ input: APP_MESSAGE_INBOX_BYTES,
31+ output: APP_MESSAGE_OUTBOX_BYTES,
23 onReadable() {32 onReadable() {
24 const incoming = this.read();33 const incoming = this.read();
25 if (controller) controller.onReadable(incoming);34 if (controller) controller.onReadable(incoming);
@@ -42,7 +51,8 @@ queue = new MessageQueue(message, {
42 });51 });
43 52
44 controller = createController({53 controller = createController({
45- clock: { now: Date.now },54+ clock: Date,
55+ scheduler: Timer,
46 queue,56 queue,
47 model: new WatchModel(),57 model: new WatchModel(),
48 storage: localStorage,58 storage: localStorage,
@@ -51,10 +61,21 @@ controller = createController({
51 controller.start();61 controller.start();
52 if (pendingChannel === "writable") queue.writable();62 if (pendingChannel === "writable") queue.writable();
53 else if (pendingChannel === "suspended") queue.suspend();63 else if (pendingChannel === "suspended") queue.suspend();
54-pendingMessages.forEach(function (incoming) {64+for (let index = 0; index < pendingMessages.length; index += 1) {
55- controller.onReadable(incoming);65+ controller.onReadable(pendingMessages[index]);
66+}
67+const button = new Button({
68+ types: ["select", "up", "down"],
69+ single: true,
70+ onPush(active, type) {
71+ if (active) controller.onButton(type);
72+ }
56 });73 });
57 74
75+watch.addEventListener("minutechange", controller.onMinuteChange);
76+
77+watch.addEventListener("willFocus", controller.setActive);
78+
58 watch.addEventListener("resize", function () {79 watch.addEventListener("resize", function () {
59 view.resize(screenInfo());80 view.resize(screenInfo());
60 controller.redraw();81 controller.redraw();
modified packages/watch/src/embeddedjs/manifest.json +1 -0
@@ -11,6 +11,7 @@
1111 "./protocol",
1212 "./message-queue",
1313 "./localization",
14+ "./presentation",
1415 "./storage",
1516 "./ui",
1617 "./contracts"
@@ -11,6 +11,7 @@
11 "./protocol",11 "./protocol",
12 "./message-queue",12 "./message-queue",
13 "./localization",13 "./localization",
14+ "./presentation",
14 "./storage",15 "./storage",
15 "./ui",16 "./ui",
16 "./contracts"17 "./contracts"
modified packages/watch/src/embeddedjs/model.js +192 -41
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/src/embeddedjs/presentation.js +108 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/protocol.js +273 -132
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/storage.js +93 -57
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/src/embeddedjs/ui.js +294 -88
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/controller-storage.test.js +520 -27
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/fixture-boot.test.js +487 -51
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/localization.test.js +62 -21
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/manifest.test.js +20 -14
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/model.test.js +373 -153
Large diff collapsed to keep the page fast — load it when you need it.
added packages/watch/test/presentation.test.js +527 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/test/protocol.test.js +152 -22
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/build.mjs +4 -5
@@ -1,5 +1,5 @@
11 import { spawnSync } from "node:child_process";
2-import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
33 import { basename, join } from "node:path";
44 import { fileURLToPath } from "node:url";
55
@@ -32,8 +32,8 @@ export function readBuildConfiguration(environment) {
3232 };
3333 }
3434
35-export function createBootstrap(fixture, configuration) {
36- return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;
35+export function createBootstrap(configuration) {
36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n readyDefer: function (callback) { setTimeout(callback, 250); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)}\n});\n`;
3737 }
3838
3939 function run(command, args, cwd = root) {
@@ -48,7 +48,6 @@ function run(command, args, cwd = root) {
4848
4949 function build() {
5050 const configuration = readBuildConfiguration(process.env);
51- const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
5251
5352 run(process.execPath, ["scripts/test.mjs"]);
5453 run(process.execPath, ["scripts/measure-radio.mjs"]);
@@ -59,7 +58,7 @@ function build() {
5958 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
6059 }
6160 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62- writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));
61+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(configuration));
6362
6463 const watch = join(root, "packages/watch");
6564 run("pebble", ["clean"], watch);
@@ -1,5 +1,5 @@
1 import { spawnSync } from "node:child_process";1 import { spawnSync } from "node:child_process";
2-import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";2+import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3 import { basename, join } from "node:path";3 import { basename, join } from "node:path";
4 import { fileURLToPath } from "node:url";4 import { fileURLToPath } from "node:url";
5 5
@@ -32,8 +32,8 @@ export function readBuildConfiguration(environment) {
32 };32 };
33 }33 }
34 34
35-export function createBootstrap(fixture, configuration) {35+export function createBootstrap(configuration) {
36- return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n readyDefer: function (callback) { setTimeout(callback, 250); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)}\n});\n`;
37 }37 }
38 38
39 function run(command, args, cwd = root) {39 function run(command, args, cwd = root) {
@@ -48,7 +48,6 @@ function run(command, args, cwd = root) {
48 48
49 function build() {49 function build() {
50 const configuration = readBuildConfiguration(process.env);50 const configuration = readBuildConfiguration(process.env);
51- const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
52 51
53 run(process.execPath, ["scripts/test.mjs"]);52 run(process.execPath, ["scripts/test.mjs"]);
54 run(process.execPath, ["scripts/measure-radio.mjs"]);53 run(process.execPath, ["scripts/measure-radio.mjs"]);
@@ -59,7 +58,7 @@ function build() {
59 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));58 cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
60 }59 }
61 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));60 cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62- writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));61+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(configuration));
63 62
64 const watch = join(root, "packages/watch");63 const watch = join(root, "packages/watch");
65 run("pebble", ["clean"], watch);64 run("pebble", ["clean"], watch);
modified scripts/measure-radio.mjs +108 -38
Large diff collapsed to keep the page fast — load it when you need it.
modified scripts/test.mjs +1 -0
@@ -25,5 +25,6 @@ run(process.execPath, [
2525 "packages/watch/test/model.test.js",
2626 "packages/watch/test/storage.test.js",
2727 "packages/watch/test/localization.test.js",
28+ "packages/watch/test/presentation.test.js",
2829 "packages/watch/test/fixture-boot.test.js",
2930 ]);
@@ -25,5 +25,6 @@ run(process.execPath, [
25 "packages/watch/test/model.test.js",25 "packages/watch/test/model.test.js",
26 "packages/watch/test/storage.test.js",26 "packages/watch/test/storage.test.js",
27 "packages/watch/test/localization.test.js",27 "packages/watch/test/localization.test.js",
28+ "packages/watch/test/presentation.test.js",
28 "packages/watch/test/fixture-boot.test.js",29 "packages/watch/test/fixture-boot.test.js",
29 ]);30 ]);