signalk-chiplog 2.12.0 → 2.13.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.13.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Remove the pairing** from the Export page, after a confirmation: the plugin forgets its token and the backup stops.
14
+ What is already on the service stays there.
15
+
16
+ ### Changed
17
+
18
+ - **Online backup says why the service cannot be reached.** Instead of "fetch failed", pairing, backup and restore name
19
+ the host and the cause (name not resolved, connection refused, certificate refused, no answer in time), with a hint
20
+ for the common ones — a wrong clock behind a certificate refused. A pairing that cannot start is written to the server
21
+ log.
22
+ - **Pairing no longer asks for the service address.** It is `https://app.tibord.com` for a new plugin; another one, for
23
+ testing or development, is set in the plugin configuration.
24
+
9
25
  ## [2.12.0] - 2026-10-24
10
26
 
11
27
  ### Added
@@ -495,7 +511,8 @@ First release.
495
511
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
496
512
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
497
513
 
498
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...HEAD
514
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...HEAD
515
+ [2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
499
516
  [2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
500
517
  [2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
501
518
  [2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
package/README.md CHANGED
@@ -537,14 +537,17 @@ Keep a copy of the logbook off the boat, on the Ti Bord online service. What it
537
537
  and how you can share your passages and voyages from it, is at [tibord.com](https://tibord.com), which the **Online
538
538
  backup** card links to as well.
539
539
 
540
- 1. On the **Export** page, under **Online backup**, enter the service address and choose **Get a pairing code**.
540
+ 1. On the **Export** page, under **Online backup**, choose **Get a pairing code**.
541
541
  2. On the service, signed in to your account, enter the code shown (e.g. `K7QF-3MXB`) and choose your boat — or scan the
542
542
  QR code beside it with your phone, which opens the service with the code filled in. The code is valid for 15 minutes.
543
- 3. The page soon says the boat is paired: Chiplog has saved the service address and its own token in the plugin
544
- configuration and turned the backup on.
543
+ 3. The page soon says the boat is paired: Chiplog has saved its own token in the plugin configuration and turned the
544
+ backup on.
545
545
 
546
- You can also paste a token by hand: turn **Back the logbook up online** on in the plugin configuration, fill in the
547
- **Online service address** and the **Device token**.
546
+ You can also paste a token by hand: turn **Back the logbook up online** on in the plugin configuration and fill in the
547
+ **Device token**. The **Online service address** is `https://app.tibord.com`; change it only to test another instance.
548
+
549
+ **Remove the pairing**, on the same card, asks for a confirmation, then forgets the token and stops the backup. What is
550
+ already on the service stays there; revoke the device from your account on the service to stop it for good.
548
551
 
549
552
  From then on, whenever the boat has a connection, Chiplog sends:
550
553
 
package/index.js CHANGED
@@ -278,7 +278,9 @@ module.exports = function (app) {
278
278
  cloudSyncUrl: {
279
279
  type: 'string',
280
280
  title: 'Online service address',
281
- description: 'The address of the Ti Bord service: https://app.tibord.com'
281
+ description:
282
+ 'The address of the Ti Bord service. Leave it as it is unless you test another instance',
283
+ default: CLOUD_SYNC_DEFAULTS.cloudSyncUrl
282
284
  },
283
285
  cloudSyncToken: {
284
286
  type: 'string',
@@ -591,6 +593,28 @@ module.exports = function (app) {
591
593
  });
592
594
  }
593
595
 
596
+ // Forgets the pairing from the Export page: the token leaves the plugin configuration and the
597
+ // backup turns off; the service address stays. What the service holds stays there.
598
+ function removePairing() {
599
+ const next = { ...savedConfig, cloudSyncEnabled: false };
600
+ delete next.cloudSyncToken;
601
+ return new Promise((resolve, reject) => {
602
+ app.savePluginOptions(next, (err) => {
603
+ if (err) {
604
+ reject(err);
605
+ return;
606
+ }
607
+ savedConfig = next;
608
+ if (settings) {
609
+ settings.cloudSyncEnabled = false;
610
+ settings.cloudSyncToken = null;
611
+ }
612
+ cloudPairing?.cancel();
613
+ resolve();
614
+ });
615
+ });
616
+ }
617
+
594
618
  // Declares the link costly or cheap from the Export page, saved like the rest of the
595
619
  // configuration. Back to cheap, a run starts at once: full passages have been waiting.
596
620
  function saveCostlyLink(costly) {
@@ -654,7 +678,7 @@ module.exports = function (app) {
654
678
  ? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
655
679
  : config.summaryMailTileUrl,
656
680
  cloudSyncEnabled: config.cloudSyncEnabled ?? CLOUD_SYNC_DEFAULTS.cloudSyncEnabled,
657
- cloudSyncUrl: config.cloudSyncUrl || null,
681
+ cloudSyncUrl: config.cloudSyncUrl || CLOUD_SYNC_DEFAULTS.cloudSyncUrl,
658
682
  cloudSyncToken: config.cloudSyncToken || null,
659
683
  cloudSyncIntervalMinutes:
660
684
  config.cloudSyncIntervalMinutes ?? CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
@@ -765,6 +789,7 @@ module.exports = function (app) {
765
789
  });
766
790
  cloudPairing = createCloudPairing({
767
791
  db: database,
792
+ serviceUrl: () => settings.cloudSyncUrl,
768
793
  userAgent: `signalk-chiplog/${version}`,
769
794
  log,
770
795
  onPaired: ({ url, token }) => savePairing(url, token)
@@ -886,6 +911,7 @@ module.exports = function (app) {
886
911
  cloudSync,
887
912
  nudgeCloudSync: () => cloudSchedule?.nudge(),
888
913
  saveCostlyLink,
914
+ removePairing,
889
915
  cloudPairing,
890
916
  cloudRestore,
891
917
  replayJob,
package/lib/api.js CHANGED
@@ -778,16 +778,36 @@ function registerRoutes(router, { getContext, logError }) {
778
778
  })
779
779
  );
780
780
 
781
+ // Forgets the pairing: the device token is removed from the plugin configuration and the
782
+ // backup turns off. The service keeps what it holds, and the device until revoked there.
783
+ admin.delete(
784
+ '/api/cloud-sync/token',
785
+ handle(async ({ cloudSync, cloudPairing, cloudRestore, removePairing }) => {
786
+ try {
787
+ await removePairing();
788
+ } catch (err) {
789
+ throw conflict('cloud_sync_save_failed', err.message);
790
+ }
791
+ return {
792
+ ...cloudSync.status(),
793
+ pairing: cloudPairing.status(),
794
+ restore: cloudRestore.status()
795
+ };
796
+ })
797
+ );
798
+
781
799
  // Asks the service for a pairing code, then waits in the background for someone to claim
782
- // it; GET /cloud-sync follows the pairing.
800
+ // it; GET /cloud-sync follows the pairing. The service is the configured one unless `url`
801
+ // names another.
783
802
  admin.post(
784
803
  '/api/cloud-sync/pairing',
785
804
  handle(async ({ cloudPairing }, req) => {
786
- if (typeof req.body?.url !== 'string' || req.body.url.trim() === '') {
787
- throw badRequest('url is required');
805
+ const url = req.body?.url;
806
+ if (url !== undefined && (typeof url !== 'string' || url.trim() === '')) {
807
+ throw badRequest('url must be a non-empty string');
788
808
  }
789
809
  try {
790
- return await cloudPairing.start(req.body.url.trim());
810
+ return await cloudPairing.start(url?.trim());
791
811
  } catch (err) {
792
812
  if (err.code === 'invalid_url') {
793
813
  throw badRequest(err.message);
@@ -1,5 +1,6 @@
1
1
  const { getLogbookId } = require('./cloud-sync');
2
2
  const { qrCode } = require('./qr');
3
+ const { fetchExplained } = require('./network-error');
3
4
 
4
5
  const REQUEST_TIMEOUT_MS = 30 * 1000;
5
6
  // When the service is out of reach mid-pairing: keep asking, as long as the code lives.
@@ -32,6 +33,8 @@ function serviceUrl(text) {
32
33
  // given the service address and the token, to save them in the plugin configuration.
33
34
  function createCloudPairing({
34
35
  db,
36
+ // The address to pair with when none is given: the one in the plugin configuration.
37
+ serviceUrl: configuredUrl = () => null,
35
38
  userAgent,
36
39
  onPaired,
37
40
  log,
@@ -44,16 +47,20 @@ function createCloudPairing({
44
47
  let timer = null;
45
48
 
46
49
  async function call(url, path, body) {
47
- const response = await fetch(`${url}/v1${path}`, {
48
- method: 'POST',
49
- headers: {
50
- 'content-type': 'application/json',
51
- 'user-agent': userAgent,
52
- 'x-chiplog-version': userAgent.split('/')[1] ?? ''
50
+ const response = await fetchExplained(
51
+ fetch,
52
+ `${url}/v1${path}`,
53
+ {
54
+ method: 'POST',
55
+ headers: {
56
+ 'content-type': 'application/json',
57
+ 'user-agent': userAgent,
58
+ 'x-chiplog-version': userAgent.split('/')[1] ?? ''
59
+ },
60
+ body: JSON.stringify(body)
53
61
  },
54
- body: JSON.stringify(body),
55
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
56
- });
62
+ REQUEST_TIMEOUT_MS
63
+ );
57
64
  const data = await response.json().catch(() => ({}));
58
65
  if (!response.ok) {
59
66
  throw new PairingError(data.message ?? `The service answered ${response.status}`, {
@@ -87,6 +94,7 @@ function createCloudPairing({
87
94
  state = { state: 'expired', at: new Date(clock()).toISOString() };
88
95
  } else if (!(error instanceof PairingError)) {
89
96
  // Out of reach for a moment: the code is still good.
97
+ log('debug', `Online backup pairing: ${error.message}; retrying`);
90
98
  schedule(current, RETRY_MS);
91
99
  } else {
92
100
  attempt = null;
@@ -119,16 +127,22 @@ function createCloudPairing({
119
127
 
120
128
  return {
121
129
  // Asks the service for a code and starts waiting for it to be claimed. A pairing already
122
- // under way is dropped: its code was not used.
123
- async start(text) {
124
- const url = serviceUrl(text);
130
+ // under way is dropped: its code was not used. Without an address, the configured one.
131
+ async start(text = configuredUrl()) {
132
+ const url = serviceUrl(text ?? '');
125
133
  if (!url) {
126
134
  throw new PairingError('The service address must be an http or https URL', {
127
135
  code: 'invalid_url'
128
136
  });
129
137
  }
130
138
  this.cancel();
131
- const pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
139
+ let pairing;
140
+ try {
141
+ pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
142
+ } catch (error) {
143
+ log('error', `Online backup pairing could not start: ${error.message}`);
144
+ throw error;
145
+ }
132
146
  const current = {
133
147
  url,
134
148
  pairingId: pairing.pairingId,
@@ -1,6 +1,7 @@
1
1
  const { withTransaction } = require('./database');
2
2
  const { getLogbookId, isRestoring } = require('./cloud-sync');
3
3
  const { TRACK_READINGS, OBSERVATION_READINGS } = require('./passage-import');
4
+ const { fetchExplained } = require('./network-error');
4
5
 
5
6
  const REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
6
7
 
@@ -250,15 +251,19 @@ function createCloudRestore({
250
251
  const isEmpty = () => db.prepare('SELECT count(*) AS n FROM log_entries').get().n === 0;
251
252
 
252
253
  async function call(path) {
253
- const response = await fetch(`${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1${path}`, {
254
- method: 'GET',
255
- headers: {
256
- authorization: `Bearer ${settings.cloudSyncToken}`,
257
- 'user-agent': userAgent,
258
- 'x-chiplog-version': userAgent.split('/')[1] ?? ''
254
+ const response = await fetchExplained(
255
+ fetch,
256
+ `${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1${path}`,
257
+ {
258
+ method: 'GET',
259
+ headers: {
260
+ authorization: `Bearer ${settings.cloudSyncToken}`,
261
+ 'user-agent': userAgent,
262
+ 'x-chiplog-version': userAgent.split('/')[1] ?? ''
263
+ }
259
264
  },
260
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
261
- });
265
+ REQUEST_TIMEOUT_MS
266
+ );
262
267
  if (!response.ok) {
263
268
  let detail = {};
264
269
  try {
package/lib/cloud-sync.js CHANGED
@@ -9,9 +9,13 @@ const { allSegments } = require('./propulsion');
9
9
  const { getTideForecast } = require('./tide-forecaster');
10
10
  const { allObservations, allTrackPoints } = require('./track');
11
11
  const { getWeatherForecast } = require('./weather-forecaster');
12
+ const { fetchExplained } = require('./network-error');
12
13
 
13
14
  const CLOUD_SYNC_DEFAULTS = {
14
15
  cloudSyncEnabled: false,
16
+ // Where every boat backs up; another address is for testing and development, set in the
17
+ // plugin configuration.
18
+ cloudSyncUrl: 'https://app.tibord.com',
15
19
  cloudSyncIntervalMinutes: 15,
16
20
  cloudSyncCostlyLink: false
17
21
  };
@@ -180,12 +184,16 @@ function createCloudSync({
180
184
  headers['content-encoding'] = 'gzip';
181
185
  payload = zlib.gzipSync(JSON.stringify(body));
182
186
  }
183
- const response = await fetch(`${base()}${path}`, {
184
- method,
185
- headers,
186
- body: payload,
187
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
188
- });
187
+ const response = await fetchExplained(
188
+ fetch,
189
+ `${base()}${path}`,
190
+ {
191
+ method,
192
+ headers,
193
+ body: payload
194
+ },
195
+ REQUEST_TIMEOUT_MS
196
+ );
189
197
  if (!response.ok) {
190
198
  let detail = {};
191
199
  try {
@@ -0,0 +1,72 @@
1
+ // Node's fetch says only "fetch failed" when no answer came back; the reason — a name that does
2
+ // not resolve, a refused connection, a certificate refused — is in `cause`, which the plugin
3
+ // would otherwise lose. These turn such a failure into one sentence for the log and the page.
4
+
5
+ // A hint for the causes a boat meets most, where the fix is not obvious from the code alone.
6
+ const HINTS = [
7
+ [/^(ENOTFOUND|EAI_AGAIN)$/, 'check the address and that this server can resolve names (DNS)'],
8
+ [
9
+ /^(CERT_|UNABLE_TO_VERIFY|SELF_SIGNED|DEPTH_ZERO|ERR_TLS_CERT)/,
10
+ "check this server's clock: a wrong date makes valid certificates look invalid"
11
+ ],
12
+ [/^(ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH)$/, 'check the address and the network']
13
+ ];
14
+
15
+ function describeCause(cause) {
16
+ if (!cause) {
17
+ return null;
18
+ }
19
+ // Several addresses tried (IPv4 and IPv6): each has its own reason.
20
+ if (Array.isArray(cause.errors) && cause.errors.length > 0) {
21
+ const parts = [
22
+ ...new Set(cause.errors.map((each) => describeCause(each)?.text).filter(Boolean))
23
+ ];
24
+ return { code: cause.code ?? cause.errors[0].code ?? null, text: parts.join('; ') };
25
+ }
26
+ const message = cause.message || String(cause);
27
+ const code = cause.code ?? null;
28
+ const text = code && !message.includes(code) ? `${code}: ${message}` : message;
29
+ return { code, text };
30
+ }
31
+
32
+ // The error a failed `fetch(url)` threw, explained. An answer the service gave is not this
33
+ // function's: only failures where no answer came back are rewritten; anything else is returned
34
+ // as it was.
35
+ function networkError(error, url, timeoutMs) {
36
+ let host;
37
+ try {
38
+ host = new URL(url).host;
39
+ } catch {
40
+ host = url;
41
+ }
42
+ if (error?.name === 'TimeoutError') {
43
+ const result = new Error(
44
+ `No answer from ${host} within ${Math.round(timeoutMs / 1000)} s: check the network`
45
+ );
46
+ result.code = 'timeout';
47
+ result.cause = error;
48
+ return result;
49
+ }
50
+ if (!(error instanceof TypeError) || error.message !== 'fetch failed') {
51
+ return error;
52
+ }
53
+ const cause = describeCause(error.cause);
54
+ const hint = cause?.code && HINTS.find(([pattern]) => pattern.test(cause.code))?.[1];
55
+ const result = new Error(
56
+ `Cannot reach ${host}${cause ? ` (${cause.text})` : ''}${hint ? `: ${hint}` : ''}`
57
+ );
58
+ result.code = cause?.code ?? 'fetch_failed';
59
+ result.cause = error;
60
+ return result;
61
+ }
62
+
63
+ // `fetch`, with what went wrong said when no answer came back.
64
+ async function fetchExplained(fetch, url, options, timeoutMs) {
65
+ try {
66
+ return await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
67
+ } catch (error) {
68
+ throw networkError(error, url, timeoutMs);
69
+ }
70
+ }
71
+
72
+ module.exports = { networkError, fetchExplained };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
package/public/app.css CHANGED
@@ -1153,12 +1153,6 @@ button.preset-on {
1153
1153
  margin-bottom: 1rem;
1154
1154
  }
1155
1155
 
1156
- .pairing-form label {
1157
- display: grid;
1158
- gap: 0.25rem;
1159
- flex: 1 1 16rem;
1160
- }
1161
-
1162
1156
  .pairing-code .code {
1163
1157
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1164
1158
  font-size: 2rem;
@@ -105,27 +105,16 @@ function PairingState({ pairing, onCancel }) {
105
105
  return null;
106
106
  }
107
107
 
108
- function PairingForm({ initialUrl, onStart, busy }) {
108
+ function PairingForm({ onStart, busy }) {
109
109
  const { t } = useLocale();
110
- const [url, setUrl] = useState(initialUrl ?? '');
111
110
  return html`
112
111
  <form
113
112
  class="pairing-form"
114
113
  onSubmit=${(event) => {
115
114
  event.preventDefault();
116
- onStart(url);
115
+ onStart();
117
116
  }}
118
117
  >
119
- <label>
120
- ${t('cloud.serviceUrl')}
121
- <input
122
- type="url"
123
- required
124
- placeholder="https://"
125
- value=${url}
126
- onInput=${(event) => setUrl(event.currentTarget.value)}
127
- />
128
- </label>
129
118
  <button type="submit" disabled=${busy}>${t('cloud.pair')}</button>
130
119
  </form>
131
120
  `;
@@ -279,6 +268,17 @@ export function CloudBackup() {
279
268
  onStart=${(logbookId) => act('POST', '/cloud-sync/restore', { logbookId })}
280
269
  />`
281
270
  }
271
+ ${
272
+ status?.configured &&
273
+ html`<button
274
+ type="button"
275
+ class="danger"
276
+ disabled=${busy}
277
+ onClick=${() => confirm(t('cloud.unpairConfirm')) && act('DELETE', '/cloud-sync/token')}
278
+ >
279
+ ${t('cloud.unpair')}
280
+ </button>`
281
+ }
282
282
  ${
283
283
  status?.pairing &&
284
284
  html`<${PairingState}
@@ -293,11 +293,7 @@ export function CloudBackup() {
293
293
  <details open=${!ready}>
294
294
  <summary>${ready ? t('cloud.pairAgain') : t('cloud.pairTitle')}</summary>
295
295
  <p>${t('cloud.pairIntro')}</p>
296
- <${PairingForm}
297
- initialUrl=${status.url}
298
- busy=${busy}
299
- onStart=${(url) => act('POST', '/cloud-sync/pairing', { url })}
300
- />
296
+ <${PairingForm} busy=${busy} onStart=${() => act('POST', '/cloud-sync/pairing', {})} />
301
297
  </details>
302
298
  `
303
299
  }
@@ -452,11 +452,13 @@ export const MESSAGES = {
452
452
  'cloud.restored': 'Logbook restored: {count} passages brought back.',
453
453
  'cloud.restoreRefused': '{count} could not be written; see the server log.',
454
454
  'cloud.restoreFailed': 'The restore stopped: {message}',
455
- 'cloud.pairAgain': 'Pair with another account or service',
455
+ 'cloud.pairAgain': 'Pair with another account',
456
+ 'cloud.unpair': 'Remove the pairing',
457
+ 'cloud.unpairConfirm':
458
+ 'Remove the pairing? The backup stops and this plugin forgets its token. What is already on Ti Bord stays there; revoke the device from your account to stop it for good.',
456
459
  'cloud.pairIntro':
457
- 'Enter the service address and ask for a code, then enter the code on the service, signed in to your account. The backup turns on as soon as it is claimed.',
460
+ 'Ask for a code, then enter it on Ti Bord, signed in to your account. The backup turns on as soon as it is claimed.',
458
461
  'cloud.learnMore': 'More about the backups, and about sharing your passages and voyages:',
459
- 'cloud.serviceUrl': 'Service address',
460
462
  'cloud.pair': 'Get a pairing code',
461
463
  'cloud.enterCode': 'Enter this code on the service:',
462
464
  'cloud.openClaim': 'Open the service to enter it',
@@ -1037,12 +1039,14 @@ export const MESSAGES = {
1037
1039
  'cloud.restored': 'Journal restauré : {count} navigations récupérées.',
1038
1040
  'cloud.restoreRefused': '{count} n’ont pas pu être écrites ; voir le journal du serveur.',
1039
1041
  'cloud.restoreFailed': 'La restauration s’est arrêtée : {message}',
1040
- 'cloud.pairAgain': 'Appairer avec un autre compte ou service',
1042
+ 'cloud.pairAgain': 'Appairer avec un autre compte',
1043
+ 'cloud.unpair': 'Supprimer l’appairage',
1044
+ 'cloud.unpairConfirm':
1045
+ 'Supprimer l’appairage ? La sauvegarde s’arrête et ce plugin oublie son jeton. Ce qui est déjà sur Ti Bord y reste ; révoquez l’appareil depuis votre compte pour l’arrêter définitivement.',
1041
1046
  'cloud.pairIntro':
1042
- 'Indiquez l’adresse du service et demandez un code, puis saisissez ce code sur le service, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
1047
+ 'Demandez un code, puis saisissez-le sur Ti Bord, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
1043
1048
  'cloud.learnMore':
1044
1049
  'Plus d’informations sur les sauvegardes et sur le partage de vos navigations et de vos voyages :',
1045
- 'cloud.serviceUrl': 'Adresse du service',
1046
1050
  'cloud.pair': 'Obtenir un code d’appairage',
1047
1051
  'cloud.enterCode': 'Saisissez ce code sur le service :',
1048
1052
  'cloud.openClaim': 'Ouvrir le service pour le saisir',