odelic 0.2.0 → 0.2.1

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.
Files changed (68) hide show
  1. package/README.md +93 -91
  2. package/bin/odelic-mcp.js +1 -1
  3. package/bin/odelic.js +1 -1
  4. package/dist/cli.d.ts +58 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +279 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/client.d.ts +125 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +354 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/config.d.ts +34 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +139 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/controller.d.ts +95 -0
  17. package/dist/controller.d.ts.map +1 -0
  18. package/dist/controller.js +210 -0
  19. package/dist/controller.js.map +1 -0
  20. package/dist/crypto.d.ts +25 -0
  21. package/dist/crypto.d.ts.map +1 -0
  22. package/dist/crypto.js +94 -0
  23. package/dist/crypto.js.map +1 -0
  24. package/dist/errors.d.ts +14 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +17 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/index.d.ts +18 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/{src → dist}/index.js +1 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/log.d.ts +18 -0
  33. package/dist/log.d.ts.map +1 -0
  34. package/dist/log.js +21 -0
  35. package/dist/log.js.map +1 -0
  36. package/dist/mcp.d.ts +23 -0
  37. package/dist/mcp.d.ts.map +1 -0
  38. package/dist/mcp.js +74 -0
  39. package/dist/mcp.js.map +1 -0
  40. package/dist/protocol.d.ts +90 -0
  41. package/dist/protocol.d.ts.map +1 -0
  42. package/dist/protocol.js +178 -0
  43. package/dist/protocol.js.map +1 -0
  44. package/dist/server.d.ts +24 -0
  45. package/dist/server.d.ts.map +1 -0
  46. package/dist/server.js +187 -0
  47. package/dist/server.js.map +1 -0
  48. package/dist/version.d.ts +2 -0
  49. package/dist/version.d.ts.map +1 -0
  50. package/dist/version.js +2 -0
  51. package/dist/version.js.map +1 -0
  52. package/package.json +19 -7
  53. package/src/{cli.js → cli.ts} +64 -50
  54. package/src/client.ts +414 -0
  55. package/src/{config.js → config.ts} +37 -23
  56. package/src/controller.ts +267 -0
  57. package/src/{crypto.js → crypto.ts} +29 -28
  58. package/src/{errors.js → errors.ts} +2 -2
  59. package/src/index.ts +17 -0
  60. package/src/log.ts +24 -0
  61. package/src/{mcp.js → mcp.ts} +31 -20
  62. package/src/{protocol.js → protocol.ts} +56 -30
  63. package/src/{server.js → server.ts} +57 -38
  64. package/src/version.ts +1 -0
  65. package/src/client.js +0 -362
  66. package/src/controller.js +0 -219
  67. package/src/log.js +0 -20
  68. package/src/version.js +0 -1
@@ -4,9 +4,12 @@
4
4
  * Mirrors jp.co.odelic.smt.remote03.act.module.ControllerAct in "central" (Tiger) mode:
5
5
  * 03 <dst vaddr 4B> <channel> <own vaddr 4B> <msg id> <args...>
6
6
  */
7
- import { InputError } from './errors.js';
7
+ import { InputError } from './errors.ts';
8
8
 
9
- export const BROADCAST = Buffer.from([0xff, 0xff, 0xff, 0xff]);
9
+ /** A light address: 4 bytes, a number (little endian) or a hex string ("00000000", "00:00:00:00"). */
10
+ export type VaddrLike = Uint8Array | number | string;
11
+
12
+ export const BROADCAST: Buffer = Buffer.from([0xff, 0xff, 0xff, 0xff]);
10
13
 
11
14
  // channels
12
15
  export const CH_TO_LIGHT = 0x20;
@@ -21,30 +24,30 @@ export const MSG_CHANGE_COLOR = 0xe2; // SM_CHANGE_COLOR (cycle colour preset)
21
24
  export const MSG_SCENE_SAVE = 0x40; // SM_SCENE_IN
22
25
  export const MSG_SCENE_PLAY = 0x42; // SM_SCENE_PLAY
23
26
  export const MSG_STATUS = 0x70; // SM_STATUS
24
- export const MSG_STATUS_RESPONSES = [0x71, 0x35]; // STATUS_MAIN / STATE_RESPONSE_FD
27
+ export const MSG_STATUS_RESPONSES: readonly number[] = [0x71, 0x35]; // STATUS_MAIN / STATE_RESPONSE_FD
25
28
 
26
29
  // special (x, y) pairs
27
- export const XY_ON = [55, 55];
28
- export const XY_OFF = [50, 50];
30
+ export const XY_ON = [55, 55] as const;
31
+ export const XY_OFF = [50, 50] as const;
29
32
 
30
33
  export const X_MAX = 20; // colour: 0 = warm (電球色) ... 20 = cool (昼光色)
31
34
  export const Y_MAX = 19; // brightness: 0 = brightest ... 19 = dimmest
32
35
 
33
- const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
36
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
34
37
 
35
38
  /** 0 (warmest) .. 100 (coolest) -> x 0..20 */
36
- export function colorToX(color) {
39
+ export function colorToX(color: number): number {
37
40
  return clamp(Math.round((color * X_MAX) / 100), 0, X_MAX);
38
41
  }
39
42
 
40
43
  /** 1..100 % -> y 19..0 (same rule as the app: (100 - b) / 5, clamped) */
41
- export function brightnessToY(brightness) {
44
+ export function brightnessToY(brightness: number): number {
42
45
  return clamp(Math.floor((100 - brightness) / 5), 0, Y_MAX);
43
46
  }
44
47
 
45
- /** 4-byte vaddr from a Buffer/Uint8Array, a number (little endian) or a hex string ("00000000", "00:00:00:00"). */
46
- export function vaddr(v) {
47
- let b;
48
+ /** 4-byte vaddr from a Buffer/Uint8Array, a number (little endian) or a hex string. */
49
+ export function vaddr(v: VaddrLike): Buffer {
50
+ let b: Buffer;
48
51
  if (v instanceof Uint8Array) {
49
52
  b = Buffer.from(v);
50
53
  } else if (typeof v === 'number') {
@@ -61,9 +64,9 @@ export function vaddr(v) {
61
64
  }
62
65
 
63
66
  /** Concatenate Buffers and byte arrays; like Python's bytes(), array values must be integers 0..255. */
64
- function bytes(...parts) {
67
+ function bytes(...parts: (Uint8Array | readonly number[])[]): Buffer {
65
68
  return Buffer.concat(parts.map((p) => {
66
- if (Buffer.isBuffer(p)) return p;
69
+ if (p instanceof Uint8Array) return p;
67
70
  for (const v of p) {
68
71
  if (!Number.isInteger(v) || v < 0 || v > 255) throw new InputError(`byte value out of range: ${v}`);
69
72
  }
@@ -71,61 +74,84 @@ function bytes(...parts) {
71
74
  }));
72
75
  }
73
76
 
74
- export function dataEvent(dst, channel, payload) {
77
+ export function dataEvent(dst: Uint8Array, channel: number, payload: Uint8Array): Buffer {
75
78
  return bytes([0x03], dst, [channel], payload);
76
79
  }
77
80
 
78
81
  /** ControllerAct.setlight: on/off/colour/brightness for one light or all (broadcast). */
79
- export function light(own, dst, x, y, index = 0) {
82
+ export function light(own: Uint8Array, dst: Buffer, x: number, y: number, index = 0): Buffer {
80
83
  const ch = dst.equals(BROADCAST) ? CH_TO_LIGHT_BROADCAST : CH_TO_LIGHT;
81
84
  return dataEvent(dst, ch, bytes(own, [MSG_LIGHT, index, x, y, 0, 0, 0, 0]));
82
85
  }
83
86
 
84
87
  /** ControllerAct.sendgroup_new */
85
- export function groupLight(own, groupId, x, y) {
88
+ export function groupLight(own: Uint8Array, groupId: number, x: number, y: number): Buffer {
86
89
  return dataEvent(BROADCAST, CH_TO_LIGHT, bytes(own, [MSG_LIGHT_GROUP, x, y, 0, 0, 0, 0, 0, 0, groupId]));
87
90
  }
88
91
 
89
- export function changeColor(own, dst) {
92
+ export function changeColor(own: Uint8Array, dst: Uint8Array): Buffer {
90
93
  return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_CHANGE_COLOR]));
91
94
  }
92
95
 
93
- export function scene(own, index, save, dst = BROADCAST) {
96
+ export function scene(own: Uint8Array, index: number, save: boolean, dst: Uint8Array = BROADCAST): Buffer {
94
97
  const msg = save ? MSG_SCENE_SAVE : MSG_SCENE_PLAY;
95
98
  return dataEvent(dst, CH_TO_LIGHT, bytes(own, [msg, 0, 0, 0, 0, 0, 0, index]));
96
99
  }
97
100
 
98
- export function statusRequest(own, dst) {
101
+ export function statusRequest(own: Uint8Array, dst: Uint8Array): Buffer {
99
102
  return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_STATUS]));
100
103
  }
101
104
 
102
- export function pingAll(own) {
105
+ export function pingAll(own: Uint8Array): Buffer {
103
106
  return dataEvent(BROADCAST, CH_PING, own);
104
107
  }
105
108
 
109
+ /** A light that answered a mesh ping. */
106
110
  export class Device {
107
- constructor({ vaddr, mac, product, version }) {
111
+ readonly vaddr: Buffer;
112
+ readonly mac: string;
113
+ readonly product: number;
114
+ readonly version: string;
115
+
116
+ constructor({ vaddr, mac, product, version }: { vaddr: Buffer; mac: string; product: number; version: string }) {
108
117
  this.vaddr = vaddr;
109
118
  this.mac = mac;
110
119
  this.product = product;
111
120
  this.version = version;
112
121
  }
113
122
 
114
- toString() {
123
+ toString(): string {
115
124
  return `vaddr=${this.vaddr.toString('hex')} mac=${this.mac} product=${this.product} ver=${this.version}`;
116
125
  }
117
126
  }
118
127
 
119
128
  /** MeshCommon.GetPingResponse; `p` is a decrypted type-3 packet with channel 0xFF. */
120
- export function parsePingResponse(p) {
129
+ export function parsePingResponse(p: Buffer): Device | null {
121
130
  if (p.length < 20 || p[0] !== 0x03 || p[5] !== CH_PING_RESPONSE) return null;
122
131
  const mac = [...p.subarray(6, 12)].reverse().map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(':');
123
132
  const product = p[16] | (p[17] << 8);
124
133
  return new Device({ vaddr: Buffer.from(p.subarray(12, 16)), mac, product, version: `${product}.${p[18]}.${p[19]}` });
125
134
  }
126
135
 
136
+ export interface LightStateJSON {
137
+ vaddr: string;
138
+ on: boolean;
139
+ /** 0 = warm .. 100 = cool, null if unknown */
140
+ color: number | null;
141
+ /** 1..100 %, 0 when off, null if unknown */
142
+ brightness: number | null;
143
+ x: number;
144
+ y: number;
145
+ }
146
+
147
+ /** State reported by one light. */
127
148
  export class LightState {
128
- constructor({ vaddr, on, x, y }) {
149
+ readonly vaddr: Buffer;
150
+ readonly on: boolean;
151
+ readonly x: number;
152
+ readonly y: number;
153
+
154
+ constructor({ vaddr, on, x, y }: { vaddr: Buffer; on: boolean; x: number; y: number }) {
129
155
  this.vaddr = vaddr;
130
156
  this.on = on;
131
157
  this.x = x;
@@ -133,23 +159,23 @@ export class LightState {
133
159
  }
134
160
 
135
161
  /** 0 = warm .. 100 = cool, null if unknown (light reports a plain "on"). */
136
- get color() {
162
+ get color(): number | null {
137
163
  return !this.on || this.x > X_MAX ? null : this.x * 5;
138
164
  }
139
165
 
140
166
  /** 1..100 %, 0 when off, null if unknown. */
141
- get brightness() {
167
+ get brightness(): number | null {
142
168
  if (!this.on) return 0;
143
169
  return this.y > Y_MAX ? null : 100 - this.y * 5;
144
170
  }
145
171
 
146
- toJSON() {
172
+ toJSON(): LightStateJSON {
147
173
  return { vaddr: this.vaddr.toString('hex'), on: this.on, color: this.color, brightness: this.brightness, x: this.x, y: this.y };
148
174
  }
149
175
  }
150
176
 
151
177
  /** Status reply (MeshBlePresenter.syncState): `03 <dst4> <ch> <src4> 71 <x> <y> ...` */
152
- export function parseStatus(p) {
178
+ export function parseStatus(p: Buffer): LightState | null {
153
179
  if (p.length < 13 || p[0] !== 0x03 || !MSG_STATUS_RESPONSES.includes(p[10])) return null;
154
180
  const x = p[11];
155
181
  const y = p[12];
@@ -157,7 +183,7 @@ export function parseStatus(p) {
157
183
  }
158
184
 
159
185
  /** The app shows "ID:<HomeID><4-char password>", e.g. "ID:12345678" -> { homeid: 1234, password: "5678" }. */
160
- export function parseAppId(appId) {
186
+ export function parseAppId(appId: string): { homeid: number; password: string } {
161
187
  let s = String(appId).trim();
162
188
  if (s.toUpperCase().startsWith('ID:')) s = s.slice(3);
163
189
  if (s.length < 5) throw new InputError(`ID looks too short: ${JSON.stringify(String(appId))}`);
@@ -170,7 +196,7 @@ export function parseAppId(appId) {
170
196
  * HomeID advertised by a mesh node (manufacturer id 0xFFFF, c0/c1 ff 01|02 <homeid LE>).
171
197
  * `manufacturerData` is the raw AD field as noble reports it, i.e. starting with the 2-byte company id.
172
198
  */
173
- export function advHomeid(manufacturerData) {
199
+ export function advHomeid(manufacturerData: Buffer | null | undefined): number | null {
174
200
  const m = manufacturerData;
175
201
  if (!m || m.length < 9 || m[0] !== 0xff || m[1] !== 0xff) return null;
176
202
  const v = m.subarray(2);
@@ -15,29 +15,42 @@
15
15
  */
16
16
  import { createHash, timingSafeEqual } from 'node:crypto';
17
17
  import http from 'node:http';
18
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
19
+ import type { AddressInfo } from 'node:net';
18
20
 
19
- import { InputError } from './errors.js';
20
- import { log } from './log.js';
21
- import { toInt } from './controller.js';
21
+ import { InputError } from './errors.ts';
22
+ import { log } from './log.ts';
23
+ import { toInt } from './controller.ts';
24
+ import type { Controller } from './controller.ts';
22
25
 
23
- export const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];
26
+ export const LOOPBACK: readonly string[] = ['127.0.0.1', 'localhost', '::1'];
24
27
  const MAX_BODY = 65536;
25
28
 
29
+ /** The part of Controller the HTTP API uses. */
30
+ export type HttpController = Pick<Controller,
31
+ 'info' | 'lights' | 'status' | 'on' | 'off' | 'setLight' | 'nightLight' | 'group' | 'scene'>;
32
+
33
+ type Params = Record<string, unknown>;
34
+
26
35
  export class HttpError extends Error {
27
- constructor(status, message) {
36
+ readonly status: number;
37
+
38
+ constructor(status: number, message: string) {
28
39
  super(message);
29
40
  this.name = 'HttpError';
30
41
  this.status = status;
31
42
  }
32
43
  }
33
44
 
34
- function need(method, expected) {
45
+ function need(method: string, expected: string): void {
35
46
  if (method !== expected) throw new HttpError(405, `use ${expected}`);
36
47
  }
37
48
 
49
+ const num = (v: unknown, def: number) => (v ?? def) as number | string;
50
+
38
51
  /** Route one request to the controller. Returns the JSON-able result. */
39
- export async function dispatch(ctl, method, path, params) {
40
- let parts;
52
+ export async function dispatch(ctl: HttpController, method: string, path: string, params: Params): Promise<unknown> {
53
+ let parts: string[];
41
54
  try {
42
55
  parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
43
56
  } catch {
@@ -60,11 +73,11 @@ export async function dispatch(ctl, method, path, params) {
60
73
  const act = parts[2];
61
74
  if (act === 'on') return ctl.on(target);
62
75
  if (act === 'off') return ctl.off(target);
63
- if (act === 'light') return ctl.setLight(target, params.color ?? 50, params.brightness ?? 100);
64
- if (act === 'night') return ctl.nightLight(target, params.level ?? 0);
76
+ if (act === 'light') return ctl.setLight(target, num(params.color, 50), num(params.brightness, 100));
77
+ if (act === 'night') return ctl.nightLight(target, num(params.level, 0));
65
78
  } else if (head === 'groups' && parts.length === 3) {
66
79
  need(method, 'POST');
67
- return ctl.group(toInt(parts[1], 'group id'), parts[2], params.color ?? 50, params.brightness ?? 100);
80
+ return ctl.group(toInt(parts[1], 'group id'), parts[2], num(params.color, 50), num(params.brightness, 100));
68
81
  } else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
69
82
  need(method, 'POST');
70
83
  return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
@@ -72,16 +85,16 @@ export async function dispatch(ctl, method, path, params) {
72
85
  throw new HttpError(404, 'not found');
73
86
  }
74
87
 
75
- function readBody(req) {
88
+ function readBody(req: IncomingMessage): Promise<Buffer> {
76
89
  return new Promise((resolve, reject) => {
77
90
  const declared = Number(req.headers['content-length'] || 0);
78
91
  if (declared > MAX_BODY) {
79
92
  reject(new HttpError(413, 'body too large'));
80
93
  return;
81
94
  }
82
- const chunks = [];
95
+ const chunks: Buffer[] = [];
83
96
  let size = 0;
84
- req.on('data', (c) => {
97
+ req.on('data', (c: Buffer) => {
85
98
  size += c.length;
86
99
  if (size > MAX_BODY) {
87
100
  reject(new HttpError(413, 'body too large'));
@@ -95,19 +108,19 @@ function readBody(req) {
95
108
  });
96
109
  }
97
110
 
98
- const digest = (s) => createHash('sha256').update(String(s)).digest();
111
+ const digest = (s: unknown) => createHash('sha256').update(String(s)).digest();
99
112
 
100
113
  /** A `(req, res)` handler for node:http. */
101
- export function makeHandler(ctl, token = null) {
102
- return async (req, res) => {
114
+ export function makeHandler(ctl: HttpController, token: string | null = null) {
115
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
103
116
  let status = 200;
104
- let payload;
117
+ let payload: unknown;
105
118
  try {
106
- const url = new URL(req.url, 'http://localhost');
107
- const params = Object.fromEntries(url.searchParams);
119
+ const url = new URL(req.url ?? '/', 'http://localhost');
120
+ const params: Params = Object.fromEntries(url.searchParams);
108
121
  const body = await readBody(req);
109
122
  if (body.length) {
110
- let data;
123
+ let data: unknown;
111
124
  try {
112
125
  data = JSON.parse(body.toString('utf8'));
113
126
  } catch {
@@ -116,22 +129,23 @@ export function makeHandler(ctl, token = null) {
116
129
  if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(params, data);
117
130
  }
118
131
  if (token) {
119
- const auth = req.headers.authorization || '';
132
+ const auth = req.headers.authorization ?? '';
120
133
  const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
121
134
  if (!timingSafeEqual(digest(given), digest(token))) throw new HttpError(401, 'bad or missing token');
122
135
  }
123
136
  delete params.token;
124
- payload = await dispatch(ctl, req.method.toUpperCase(), url.pathname, params);
137
+ payload = await dispatch(ctl, (req.method ?? 'GET').toUpperCase(), url.pathname, params);
125
138
  } catch (e) {
126
- if (e instanceof HttpError) {
127
- status = e.status;
128
- } else if (e instanceof InputError) {
139
+ const err = e as Error;
140
+ if (err instanceof HttpError) {
141
+ status = err.status;
142
+ } else if (err instanceof InputError) {
129
143
  status = 400;
130
144
  } else { // BLE / login failures
131
145
  status = 502;
132
- log.error('request failed:', e);
146
+ log.error('request failed:', err);
133
147
  }
134
- payload = { ok: false, error: e instanceof HttpError || e instanceof InputError ? e.message : `${e.name}: ${e.message}` };
148
+ payload = { ok: false, error: err instanceof HttpError || err instanceof InputError ? err.message : `${err.name}: ${err.message}` };
135
149
  }
136
150
  const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
137
151
  if (res.headersSent || res.destroyed) return;
@@ -144,33 +158,38 @@ export function makeHandler(ctl, token = null) {
144
158
  };
145
159
  }
146
160
 
147
- /**
148
- * Start the HTTP API. Resolves to the listening http.Server; closing the server also closes the controller.
149
- * @param {import('./controller.js').Controller} ctl
150
- * @param {{host?: string, port?: number, token?: string|null}} [opts]
151
- */
152
- export async function serve(ctl, { host = '127.0.0.1', port = 8765, token = null } = {}) {
161
+ export interface ServeOptions {
162
+ host?: string;
163
+ port?: number;
164
+ /** bearer token; required when listening on a non-loopback address */
165
+ token?: string | null;
166
+ }
167
+
168
+ /** Start the HTTP API. Resolves to the listening server; closing the server also closes the controller. */
169
+ export async function serve(ctl: Controller, { host = '127.0.0.1', port = 8765, token = null }: ServeOptions = {}): Promise<Server> {
153
170
  if (!LOOPBACK.includes(host) && !token) {
154
171
  throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
155
172
  }
156
173
  const server = http.createServer(makeHandler(ctl, token));
157
174
  server.headersTimeout = 10_000;
158
175
  server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
159
- await new Promise((resolve, reject) => {
176
+ await new Promise<void>((resolve, reject) => {
160
177
  server.once('error', reject);
161
178
  server.listen(port, host, () => {
162
179
  server.removeListener('error', reject);
163
180
  resolve();
164
181
  });
165
182
  });
166
- server.on('close', () => ctl.close().catch(() => {}));
167
- const addr = server.address();
183
+ server.on('close', () => {
184
+ ctl.close().catch(() => {});
185
+ });
186
+ const addr = server.address() as AddressInfo;
168
187
  log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
169
188
  ? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
170
189
  if (ctl.keepConnected) {
171
190
  ctl.connect()
172
191
  .then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
173
- .catch((e) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
192
+ .catch((e: Error) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
174
193
  }
175
194
  return server;
176
195
  }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '0.2.1';