@thinger-io/cli-core 0.2.1 → 0.4.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 +87 -0
- package/README.md +31 -6
- package/docs/thinger-concepts.md +389 -0
- package/docs/thinger-product-code.md +159 -0
- package/docs/thinger-server-admin.md +165 -0
- package/lib/cli.js +13 -3
- package/lib/commands/auth.js +20 -1
- package/lib/commands/dashboard.js +262 -0
- package/lib/config.js +42 -0
- package/lib/dashboard-dev.js +180 -0
- package/lib/dashboard-init.js +417 -0
- package/lib/dashboard-sdk.js +184 -0
- package/lib/dashboard.js +171 -28
- package/lib/docs.js +110 -110
- package/lib/identity.js +2 -0
- package/lib/mcp/catalog.js +12 -2
- package/lib/mcp/registry.js +8 -6
- package/lib/mcp/server.js +11 -2
- package/lib/mcp/tools-dashboards.js +169 -30
- package/lib/mcp/tools-misc.js +1 -1
- package/lib/mcp/tools-resource.js +387 -50
- package/lib/tgz.js +66 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-10-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `dashboard init <dir>` and MCP tool `dashboard_init`: a new dashboard app
|
|
8
|
+
from a template that `@thinger-io/dashboard-sdk` ships (`--template`,
|
|
9
|
+
React by default), downloaded from npm (latest release, integrity
|
|
10
|
+
checked, extracted without a `tar` binary) or, offline, taken from the
|
|
11
|
+
copy cli-core depends on; `--from` takes a version or tag, or a local
|
|
12
|
+
folder or .tgz of the package. The app gets the SDK as a dependency, the
|
|
13
|
+
manifest (storage `app_<folder>_<random>`), an AGENTS.md (and CLAUDE.md
|
|
14
|
+
pointing at it) with the rules of the console's frame and how to review
|
|
15
|
+
the layout, and with `--ci` a GitHub workflow that publishes it
|
|
16
|
+
(`npmPackage` in the identity names the CLI it runs).
|
|
17
|
+
- `dashboard dev [url]` and MCP tool `dashboard_dev`: develop a dashboard
|
|
18
|
+
app on its own dev server (ng serve, Vite…) against a real server. It
|
|
19
|
+
returns a link to the local app that opens it as a standalone page with
|
|
20
|
+
the server (`thinger_api`), a temporary full-access token
|
|
21
|
+
(`dashboard_dev[_<project>]`, 8 h by default, replaced by the next link)
|
|
22
|
+
and the context of a destination in its fragment. The url must be on
|
|
23
|
+
this machine unless `--allow-remote` (`allow_remote`); it says when
|
|
24
|
+
nothing answers there.
|
|
25
|
+
- `dashboard publish [dir]`: the command for `publishApp`, with
|
|
26
|
+
destinations as `device:<id>`, `dashboard:<id>`…
|
|
27
|
+
- An app's manifest (`<cli>.json`, section `dashboard`: `dir`, `storage`,
|
|
28
|
+
`prune`, `to`, `dev.url`, `dev.context`, `dev.route`, `dev.hours`) gives
|
|
29
|
+
these commands their defaults; `dir` must stay inside the manifest's
|
|
30
|
+
folder and the fields are type-checked.
|
|
31
|
+
- SDK compatibility: publish and dev read the SDK the app is built with
|
|
32
|
+
(`node_modules/@thinger-io/dashboard-sdk`, from the build or project
|
|
33
|
+
folder up) and its `compat.json`, and warn when the console gives it no
|
|
34
|
+
token (SDK before 0.3.0), when the profile's server
|
|
35
|
+
(`/v1/server/version`) is older than the SDK or some of its calls need,
|
|
36
|
+
when npm has a newer SDK, or when the page loads the deprecated script
|
|
37
|
+
build.
|
|
38
|
+
- `<PREFIX>_TOKEN` and `<PREFIX>_SERVER` stand for a profile (CI) unless a
|
|
39
|
+
profile is named; the token never goes to the identity's default server,
|
|
40
|
+
and `logout` says so instead of logging out of nothing.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Documentation no longer comes from the server (which stops serving
|
|
45
|
+
`/sdk/`), with the same ids: the platform's guides (`concepts`,
|
|
46
|
+
`product-code`, `server-admin`) ship in cli-core (`docs/`), marking what
|
|
47
|
+
needs a given server version ("since Thinger 7.5"); the dashboard SDK's
|
|
48
|
+
(`dashboard-sdk`, the reference, and `dashboard-sdk-types`) come from its
|
|
49
|
+
package: the version installed in the project of the working folder,
|
|
50
|
+
else the CLI's own (cli-core depends on `@thinger-io/dashboard-sdk`).
|
|
51
|
+
`doc_read`, the MCP resources and `resource_describe` read them without
|
|
52
|
+
a request.
|
|
53
|
+
- Preview and dev links refuse a route with `&` or `#`, which could add
|
|
54
|
+
parameters of its own to the fragment.
|
|
55
|
+
|
|
56
|
+
### Removed
|
|
57
|
+
|
|
58
|
+
- `FALLBACK_DOCS` and `validDoc` (docs.js): no document is read from the
|
|
59
|
+
server.
|
|
60
|
+
- The layout check link of preview links (`layoutUrl`, `LAYOUT_SIZES`):
|
|
61
|
+
the server retires `/sdk/preview.html`. The AGENTS.md of `dashboard init`
|
|
62
|
+
says how to review the layout instead.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- The catalog note on dashboards named `target` where `dashboard_publish`
|
|
67
|
+
takes `to`.
|
|
68
|
+
- `defineCliDocs` files given as `URL` resolve on Windows too.
|
|
69
|
+
|
|
70
|
+
## [0.3.0] - 2026-10-14
|
|
71
|
+
|
|
72
|
+
### Added
|
|
73
|
+
|
|
74
|
+
- `runCli({ resourceTypes, resourceActions })`: a CLI adds its platform's
|
|
75
|
+
own resource types (functions instead of a REST path) and actions on the
|
|
76
|
+
server's types, served by the same `resource_*` tools: listed in
|
|
77
|
+
`resource_describe`, searched and paged by `resource_list`, their parent
|
|
78
|
+
and id checked like the server's. Clashes with the catalog fail at
|
|
79
|
+
startup. ThinRemote serves its files, scripts, metrics, log sources and
|
|
80
|
+
playbooks this way.
|
|
81
|
+
- `alarm_instance` action `stats`: how many instances there are in each
|
|
82
|
+
state.
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
|
|
86
|
+
- `resource_list` pages lists answered whole from any `index`, not only
|
|
87
|
+
when they are longer than a page.
|
|
88
|
+
- `resource_describe` says which server actions take no id.
|
|
89
|
+
|
|
3
90
|
## [0.2.1] - 2026-10-12
|
|
4
91
|
|
|
5
92
|
### Changed
|
package/README.md
CHANGED
|
@@ -12,16 +12,29 @@ It holds everything that is not specific to a product:
|
|
|
12
12
|
pasted token, OAuth device flow; interactive or fully non-interactive.
|
|
13
13
|
- **Profiles** (`config`) — several servers per CLI, selected by `--profile`,
|
|
14
14
|
`<PREFIX>_PROFILE` or the saved default, and scoped per call
|
|
15
|
-
(`withActiveProfile`) so concurrent MCP calls don't mix servers.
|
|
15
|
+
(`withActiveProfile`) so concurrent MCP calls don't mix servers. In CI,
|
|
16
|
+
`<PREFIX>_SERVER` and `<PREFIX>_TOKEN` stand for a profile without any
|
|
17
|
+
file.
|
|
16
18
|
- **REST helpers** — `devices`, `property`, `resource`, `bucket`, `product`,
|
|
17
19
|
`profile` (product profiles), `tokens`, `alarms`, `storage`, with every
|
|
18
20
|
account path built in `paths`.
|
|
19
21
|
- **Generic commands** (`cli`, `commands/*`) — login, logout, profile, device,
|
|
20
|
-
product, bucket, mcp.
|
|
21
|
-
- **
|
|
22
|
+
product, bucket, dashboard, mcp.
|
|
23
|
+
- **Dashboard apps** (`dashboard`, `dashboard-init`, `dashboard-dev`,
|
|
24
|
+
`dashboard-sdk`) — create an app from a template of
|
|
25
|
+
`@thinger-io/dashboard-sdk`, develop it on its own dev server (a link with
|
|
26
|
+
a temporary token opens the local app against the server), and publish
|
|
27
|
+
its build to a public storage that dashboards point at, with a warning
|
|
28
|
+
when its SDK needs a newer server or is not the latest.
|
|
29
|
+
- **Documentation** (`docs`, `docs/`) — the guides for agents: the
|
|
30
|
+
platform's concepts, product code and server administration (shipped
|
|
31
|
+
here, marking what needs a given server version), the dashboard SDK's
|
|
32
|
+
reference and types (from its package: the project's version, else the
|
|
33
|
+
CLI's), and the CLI's own.
|
|
34
|
+
- **MCP** (`mcp/*`) — the stdio server and 25 generic tools: eight
|
|
22
35
|
`resource_*` tools over a catalog of the server's resource types, and the
|
|
23
36
|
operations that are not CRUD (bucket data, device resources, storage
|
|
24
|
-
files, dashboard publishing, the
|
|
37
|
+
files, dashboard development and publishing, the documentation…), plus that
|
|
25
38
|
documentation as MCP resources.
|
|
26
39
|
- **OpenAPI** (`openapi`) — the server's own API description, cached per
|
|
27
40
|
server and version, with a bundled copy as fallback.
|
|
@@ -40,7 +53,7 @@ defineIdentity({
|
|
|
40
53
|
displayName: 'Acme IoT',
|
|
41
54
|
description: 'CLI for Acme IoT servers',
|
|
42
55
|
configDir: 'acme-cli', // ~/.config/acme-cli/config.json
|
|
43
|
-
envPrefix: 'ACME', // ACME_PROFILE, ACME_INSECURE
|
|
56
|
+
envPrefix: 'ACME', // ACME_PROFILE, ACME_INSECURE, ACME_SERVER + ACME_TOKEN
|
|
44
57
|
token: { id: 'acme_cli', name: 'Acme CLI' }, // minted by the password login
|
|
45
58
|
oauth: { clientId: null }, // OAuth device-flow client, if the platform ships one
|
|
46
59
|
defaultServer: null, // suggested by the login prompt
|
|
@@ -59,7 +72,7 @@ servers.
|
|
|
59
72
|
|
|
60
73
|
`runCli` takes:
|
|
61
74
|
|
|
62
|
-
- `register(program, { device, product, bucket })` — add top-level commands,
|
|
75
|
+
- `register(program, { device, product, bucket, dashboard })` — add top-level commands,
|
|
63
76
|
or subcommands inside the generic groups.
|
|
64
77
|
- `onNoCommand(program)` — what to do when called without arguments
|
|
65
78
|
(default: help).
|
|
@@ -70,6 +83,18 @@ servers.
|
|
|
70
83
|
the same name.
|
|
71
84
|
- `resourceHooks` — the CLI's hooks on catalog types (`{ product: {
|
|
72
85
|
afterDelete } }`), run after the core's: see `mcp/resource-hooks.js`.
|
|
86
|
+
- `resourceTypes` — resource types of the CLI's own platform, served by the
|
|
87
|
+
same `resource_*` tools as the server's: instead of a REST path each has
|
|
88
|
+
functions for the verbs it supports (`list`, `get`, `create`, `update`,
|
|
89
|
+
`delete`), its `parent` and `id`, a `schema` and `notes` for
|
|
90
|
+
`resource_describe`, and `actions` for `resource_call` (`on: 'item'`,
|
|
91
|
+
`'collection'` or `'either'`). The core checks the parent and id, and
|
|
92
|
+
searches and pages what `list` returns. See `LocalType` in
|
|
93
|
+
`mcp/tools-resource.js`.
|
|
94
|
+
- `resourceActions` — actions the CLI adds to the server's types
|
|
95
|
+
(`{ device: { reboot: { description, run } } }`), run with
|
|
96
|
+
`resource_call` like the server's. A type or action that clashes with the
|
|
97
|
+
catalog fails at startup.
|
|
73
98
|
- `mcpInstructions` — server-level guidance sent to MCP clients.
|
|
74
99
|
|
|
75
100
|
## MCP tools
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Thinger concepts
|
|
2
|
+
|
|
3
|
+
How a Thinger.io account is organized, for agents that create resources, dashboards or
|
|
4
|
+
alarms. It describes Thinger 7.5: what needs a later version says "since Thinger X", and what
|
|
5
|
+
older servers lack says "since Thinger 7.5" (a server's version: `GET /v1/server/version`).
|
|
6
|
+
`GET /v1/server/events` lists the live events; the OpenAPI document lists every route. Related
|
|
7
|
+
guides: product code (document `product-code`) and server administration (document
|
|
8
|
+
`server-admin`). The dashboard SDK is the npm package `@thinger-io/dashboard-sdk`, documented in
|
|
9
|
+
its README (document `dashboard-sdk`).
|
|
10
|
+
|
|
11
|
+
## The account and its projects
|
|
12
|
+
|
|
13
|
+
Everything (devices, buckets, products, dashboards…) belongs to a **user account** and lives
|
|
14
|
+
under `/v{1,2,3}/users/<user>/…`.
|
|
15
|
+
|
|
16
|
+
A **project** groups resources of the account so they can be shared and browsed together:
|
|
17
|
+
|
|
18
|
+
- A resource belongs to projects through its `project` field, an array of `"<owner>@<project>"`.
|
|
19
|
+
One resource can be in several projects. Devices, products, buckets, dashboards, endpoints,
|
|
20
|
+
storages, access tokens, asset types and groups, alarm rules and instances, plugins, claims
|
|
21
|
+
and syncs can be in projects; properties follow their resource.
|
|
22
|
+
- `?project=<id>` on an API call scopes it: creates add the resource to the project, lists only
|
|
23
|
+
return its resources. `PUT <resource path>/projects` with `["<owner>@<project>", …]` sets the
|
|
24
|
+
list, **replacing** the previous one: read it first to add or remove one project.
|
|
25
|
+
- **Members** are other users invited to a project, with permissions from their own allow/deny
|
|
26
|
+
plus project and global roles. They call `/v1/users/<owner>/…?project=<id>`; the server
|
|
27
|
+
checks that the target resource is in the project and runs the call as the owner. A member
|
|
28
|
+
can also be limited to some asset groups.
|
|
29
|
+
- A project has its own properties. Its `dashboard` property
|
|
30
|
+
(`/v1/users/<owner>/projects/<project>/properties/dashboard`) is the project's home page:
|
|
31
|
+
members land on it when they log in, and the console menu shows it under the project name.
|
|
32
|
+
A member sees only the resources in the project, and only those its permissions allow.
|
|
33
|
+
- **Bucket data inside a project** (`config.limit_bucket_access`): a bucket in a project may also
|
|
34
|
+
hold records of devices outside it, typically a product bucket shared by several projects.
|
|
35
|
+
With this option on, every bucket read and tag listing made over the project
|
|
36
|
+
(`?project=`, members, project-scoped tokens and the dashboard SDK) only returns the records
|
|
37
|
+
of the project's devices, and asking for another device is refused. It needs the bucket to
|
|
38
|
+
carry a `device` tag (product buckets do); a bucket without it is not filtered. A project with
|
|
39
|
+
no devices reads such a bucket as empty. It is **on by default for new projects** since Thinger
|
|
40
|
+
7.5 (send
|
|
41
|
+
`config: {limit_bucket_access: false}` to opt out); for older projects, turn it on with
|
|
42
|
+
`PUT /v1/users/<u>/projects/<p>` and `{config: {limit_bucket_access: true}}`, especially before
|
|
43
|
+
sharing a project with members or public tokens.
|
|
44
|
+
|
|
45
|
+
## Devices
|
|
46
|
+
|
|
47
|
+
A device is one connected thing. `type` decides how it talks to the platform:
|
|
48
|
+
|
|
49
|
+
| type | connection | notes |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `Generic` | IOTMP (Thinger client libraries) | has credentials; exposes its own resources |
|
|
52
|
+
| `MQTT` | MQTT broker | has credentials; publishes topics |
|
|
53
|
+
| `HTTP` | posts JSON to its callback URL | no persistent connection |
|
|
54
|
+
| `Virtual` | server-side, connected while enabled | no hardware; every resource comes from its product |
|
|
55
|
+
| `Subdevice` | through a parent device (`parent`) | e.g. sensors behind a gateway |
|
|
56
|
+
|
|
57
|
+
Other fields: `name`, `description`, `product`, `asset_type`, `asset_group`, `labels`
|
|
58
|
+
(free key → string map), `enabled`, and `connection` (`active`, `ts`, `location`…).
|
|
59
|
+
|
|
60
|
+
A device has three kinds of data. Do not mix them up:
|
|
61
|
+
|
|
62
|
+
- **Resources**: the device's live API (`/v3/…/devices/<d>/resources/<r>`). Reading one asks
|
|
63
|
+
the device right now; calling one runs an action on it. They can be streamed every N
|
|
64
|
+
seconds or on change. They only work while the device is connected. When it is offline,
|
|
65
|
+
`?shadow` returns the last value saved at disconnection. For HTTP, MQTT and Virtual devices,
|
|
66
|
+
resources only exist through their product's API.
|
|
67
|
+
- **Properties**: stored JSON values about the device (configuration, state, metadata, a
|
|
68
|
+
`location`). They persist while the device is offline and can be changed from the cloud.
|
|
69
|
+
The `location` property (`{ "lat": 40.41, "lon": -3.70 }`, numbers; `lng`, `latitude` /
|
|
70
|
+
`longitude` and an optional `radius` are also accepted) fixes the device on maps
|
|
71
|
+
(`devices.locations`, `GET /v1/users/<u>/assets/locations`). Devices without it are placed by
|
|
72
|
+
their IP when they connect, which is meaningless for Virtual devices: set `location` on
|
|
73
|
+
them to place them on a map.
|
|
74
|
+
- **Buckets**: time series. A device does not own buckets; its data is written into buckets.
|
|
75
|
+
|
|
76
|
+
## Products
|
|
77
|
+
|
|
78
|
+
A product is a template for a family of devices. A device joins it through its `product`
|
|
79
|
+
field, set directly or by **autoprovision**: a device id pattern that creates the device the
|
|
80
|
+
first time it connects. The product profile defines for all of its devices:
|
|
81
|
+
|
|
82
|
+
- `properties`: device properties with defaults, forms and permissions; can also be filled
|
|
83
|
+
from MQTT topics, resource streams or events. An entry with `default.value` (any value but
|
|
84
|
+
`null`: `{}`, `[]`, `""`, `0` and `false` count), `form` or `perm` becomes a property of the
|
|
85
|
+
product, which every device of the product inherits (`source: "product"`) until it sets its own.
|
|
86
|
+
- `buckets`: where each device's data is stored. A bucket id containing `{{device}}` gives one
|
|
87
|
+
bucket per device. Otherwise one shared bucket is used, tagged with `device` (and `group`).
|
|
88
|
+
These buckets are created on the first write, have `product=<p>` and are not in any project:
|
|
89
|
+
projects get resources only by explicit assignment. Pick the access by use case:
|
|
90
|
+
- one device (device and product dashboards): read through the device
|
|
91
|
+
(`GET /v2/users/<u>/devices/<d>/buckets/<b>/data`, permission `Device:ReadDeviceBucket`; the
|
|
92
|
+
SDK's `device` option). Access to the device is enough; the bucket stays out of the project.
|
|
93
|
+
Prefer this whenever it covers the need.
|
|
94
|
+
- the whole fleet (project dashboards, aggregates across devices): add the bucket to the project.
|
|
95
|
+
- `api`: resources added to every device of the product. They map to topics, device resources,
|
|
96
|
+
properties, functions or endpoint calls, so HTTP and MQTT devices get an API too.
|
|
97
|
+
- `flows`: move data from a source (topic, resource, event…) to a sink.
|
|
98
|
+
- `code`: JavaScript functions used by the profile: values computed by code, payload
|
|
99
|
+
transforms, `device_id_resolver`… See the document `product-code`.
|
|
100
|
+
- `autoprovisions`, and `services` (web / VNC proxies to the device).
|
|
101
|
+
|
|
102
|
+
`POST /v1/…/products/<p>/resources/<r>` calls a product API resource without naming a device.
|
|
103
|
+
It only reaches a device when that resource defines a `device_id_resolver` function.
|
|
104
|
+
|
|
105
|
+
**API resources** (`profile.api.<resource>`) are what a device of the product can be asked to do.
|
|
106
|
+
`request.data.target` says where the call goes:
|
|
107
|
+
|
|
108
|
+
| target | does | typical use |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `topic` | publishes the payload on an MQTT `topic` (templated, e.g. `shellies/{{device}}/relay/0/command`) | commanding MQTT devices |
|
|
111
|
+
| `resource` | calls a resource of the device (firmware) or another API resource | wrapping or renaming device resources |
|
|
112
|
+
| `resource_stream` | emits the payload as data of this resource, without a device behind it | data pushed by a plugin or an HTTP callback (e.g. a LoRaWAN `uplink`), consumed by profile buckets / properties with `source: "resource"` |
|
|
113
|
+
| `property` | stores the payload in the device property `property` | settings sent from a dashboard; state |
|
|
114
|
+
| `function` | runs a function of the product code | values computed by code, simulated devices |
|
|
115
|
+
| `endpoint_call` | calls an endpoint (email, webhook…) | notifications, external services |
|
|
116
|
+
| `plugin_endpoint` | calls an HTTP path of a plugin (`plugin`, `path`) | network servers, e.g. a LoRaWAN `downlink` |
|
|
117
|
+
| `product_stream` | emits a product-level stream | fan-out to the whole product |
|
|
118
|
+
|
|
119
|
+
`response.data.source` says what the caller gets back: `request_response` (the target's
|
|
120
|
+
result), `property`, `resource`, `topic` (waits for a message on a topic), `function` or nothing.
|
|
121
|
+
Payloads are templates: `{{payload.port=85}}` reads input with a default, `{{property.x}}`
|
|
122
|
+
reads device state, `{{payload:fn}}` converts with a product code function. A product-level call
|
|
123
|
+
(`POST /v1/…/products/<p>/resources/<r>`) needs `device_id_resolver` to know the device.
|
|
124
|
+
|
|
125
|
+
The plugin catalog (`GET /v1/users/<u>/plugins?filter=available`) ships complete products for
|
|
126
|
+
real hardware (Shelly, Milesight, Siemens LOGO, LoRaWAN templates…): each manifest has
|
|
127
|
+
`resources.products[].profile` with working API resources, buckets and properties to learn from
|
|
128
|
+
or reuse.
|
|
129
|
+
|
|
130
|
+
**Devices without hardware.** A `Virtual` device of a product gets each of its resources from
|
|
131
|
+
the product API, and an API resource can be computed by a function of the product code. A
|
|
132
|
+
profile bucket (or property) with `source: "resource"`, `update: "interval"` calls that resource
|
|
133
|
+
on every connected device of the product, Virtual ones included, so data flows with nothing
|
|
134
|
+
outside the server. Combined with a history import (Buckets), this gives realistic fleets for
|
|
135
|
+
demos and tests.
|
|
136
|
+
|
|
137
|
+
## Buckets
|
|
138
|
+
|
|
139
|
+
A bucket stores timestamped records. Where its data comes from (`config.source`):
|
|
140
|
+
|
|
141
|
+
- `device`: the server reads a device resource every `interval` seconds, or on its events.
|
|
142
|
+
- `mqtt`: messages published on a topic.
|
|
143
|
+
- `write` / `api`: anyone with permission writes it (API, device firmware, HTTP callbacks,
|
|
144
|
+
product handlers).
|
|
145
|
+
|
|
146
|
+
`config.tags` (up to 5) are fields indexed for filtering, e.g. `device` or `site`. They are
|
|
147
|
+
read with `tag_<name>=<value>`, and their values can be listed. A **device bucket**
|
|
148
|
+
(`/v3/…/devices/<d>/buckets/<b>`) is not separate storage: it is the bucket filtered by
|
|
149
|
+
`device=<d>`, and it is read-only. That filter needs a `device` tag: product buckets have it
|
|
150
|
+
automatically; a bucket you create yourself needs `config.tags: ["device"]`, or per-device reads
|
|
151
|
+
come back empty. Backends: MongoDB, InfluxDB, DynamoDB; `retention` sets how long data is kept.
|
|
152
|
+
|
|
153
|
+
**Product profile buckets** (`profile.buckets.<id>`): with `data.source: "resource"`,
|
|
154
|
+
`data.resource`, `update: "interval"`, `interval` + `magnitude` (`second`, `minute`, `hour`…) and
|
|
155
|
+
either `payload_type: "source_payload"` or `payload: "{{payload}}"` (the two are equivalent), the
|
|
156
|
+
server samples that resource on each device of the product and writes the result. With neither,
|
|
157
|
+
the payload is the default `template_payload` with an empty template, and nothing is written.
|
|
158
|
+
Records get a `device` field; the bucket is created on the first write.
|
|
159
|
+
|
|
160
|
+
**Field names**: `ts` is the record time. In MongoDB buckets `tags` and `_id` are reserved (they
|
|
161
|
+
hold the record's tags and id): a record with a field of that name is refused, so rename it
|
|
162
|
+
(`tag_count`, …). Since Thinger 7.5 the refusal names the field.
|
|
163
|
+
|
|
164
|
+
**Reading** (`GET /v2/users/<u>/buckets/<b>/data`, or the SDK's `buckets.read`): records come as
|
|
165
|
+
`{ ts, ...fields }`, newest first. Query: `min_ts` / `max_ts` (ms), `items` (default 50, **at most
|
|
166
|
+
1000** per call), `sort` (`desc` / `asc`), `agg` (`5m`, `1h`, `1d`…) with `agg_type` (`mean`,
|
|
167
|
+
`min`, `max`, `sum`, `count`…), `group_by` (e.g. `device`), `fields`, `tag_<name>=<value>`
|
|
168
|
+
(repeat for several values), `tz`. For long ranges, aggregate; to read everything, page backwards with
|
|
169
|
+
`max_ts` = oldest `ts` received − 1. With `agg` or `group_by`, each of the `fields` is reduced on
|
|
170
|
+
its own (`group_by` without `agg_type` gives each field's latest value): a record or group that
|
|
171
|
+
lacks one field still counts for the others, and a field without values is left out of the row.
|
|
172
|
+
Without `fields`, an aggregation covers the **numeric** fields of the latest record in the range
|
|
173
|
+
(nested ones too, up to 64): name string fields explicitly in `fields` to get them with `last`,
|
|
174
|
+
`first`, `mode` or `count`.
|
|
175
|
+
|
|
176
|
+
Aggregation windows (`1h`, `1d`, `1w`…) start at midnight, hour boundaries, etc. of the time zone
|
|
177
|
+
in `tz` (IANA name, e.g. `Europe/Madrid`); without it, **UTC**. So daily totals for a site need
|
|
178
|
+
`tz` set to the site's zone, or the days split at UTC midnight. The dashboard SDK sends the
|
|
179
|
+
viewer's browser zone unless told otherwise (`timeZone` option). A record falls in the window that contains its
|
|
180
|
+
`ts`, so a window covers [start, start + period): when importing pre-aggregated history (e.g. one
|
|
181
|
+
record per 5 minutes), stamp each record with the START of the period it summarizes.
|
|
182
|
+
|
|
183
|
+
**Writing history**:
|
|
184
|
+
|
|
185
|
+
- `POST /v1/users/<u>/buckets/<b>/data` with an object or an array of objects; a numeric `ts`
|
|
186
|
+
(ms) in each object sets its time (MongoDB and InfluxDB).
|
|
187
|
+
- `POST /v1/users/<u>/buckets/<b>/imports` with
|
|
188
|
+
`{ "source": "storage", "storage": "<id>", "file": "<path>.csv" }` loads a CSV from a file
|
|
189
|
+
storage into an existing bucket. First row is the header; separator `,` or `;`. A `ts` (or
|
|
190
|
+
`date` / `timestamp`) column holds the time in **milliseconds** or ISO 8601 (UTC); seconds are
|
|
191
|
+
misread as milliseconds. Other columns become fields (numbers when they parse), and a column
|
|
192
|
+
named like a bucket tag (e.g. `device`) becomes that tag. The response streams
|
|
193
|
+
`{"progress": N}` lines. Not for DynamoDB buckets.
|
|
194
|
+
|
|
195
|
+
## Asset types and asset groups
|
|
196
|
+
|
|
197
|
+
Classification shared by devices (and buckets):
|
|
198
|
+
|
|
199
|
+
- **Asset type** (`asset_type`): what a device is (boiler, meter…).
|
|
200
|
+
- **Asset group** (`asset_group`): where or whose it is. Groups nest (`parent`), and project
|
|
201
|
+
members can be limited to some groups.
|
|
202
|
+
|
|
203
|
+
Both are created on the fly when a device references them. Both have properties that their
|
|
204
|
+
devices inherit, and both have location endpoints for maps.
|
|
205
|
+
|
|
206
|
+
## Properties and inheritance
|
|
207
|
+
|
|
208
|
+
Devices, products, projects, asset types and asset groups all have properties. When reading
|
|
209
|
+
a device property, the first level that defines it wins: **device → asset type → asset group →
|
|
210
|
+
product** (the device's own group, not its parent groups). The whole value is taken from that
|
|
211
|
+
level: values are not merged, so a property set on the device hides the one of its group or
|
|
212
|
+
product. The same lookup applies to `{{property.<id>}}` in product templates, so a setting shared
|
|
213
|
+
by many devices (a schedule, a threshold) can live on their group or product and be overridden on
|
|
214
|
+
one device. The product can force permissions (`perm`: hidden, read-only for members). The property
|
|
215
|
+
`dashboard` on any of them is the dashboard the console shows for that resource. A `null` inside a
|
|
216
|
+
stored value currently reads back as `""` (empty string): use another marker, or leave the key out,
|
|
217
|
+
when null matters.
|
|
218
|
+
|
|
219
|
+
## Geofences
|
|
220
|
+
|
|
221
|
+
Since Thinger 7.5. The server checks geofences natively: do not compute zone entries and exits in your own code. A
|
|
222
|
+
property `geofences` on the device, or inherited from its type, group or product, holds a GeoJSON
|
|
223
|
+
`FeatureCollection`. Each `Feature` is a `Polygon`, or a `Point` with `properties.radius` in
|
|
224
|
+
meters. **Coordinates are `[lon, lat]`**, as in GeoJSON (the `location` property is `{lat, lon}`).
|
|
225
|
+
The whole collection comes from the first level that defines it: a device's own `geofences` replaces
|
|
226
|
+
its product's, they are not merged. Give each feature a stable GeoJSON `id` (or `properties.id`), so
|
|
227
|
+
renaming a zone does not break its history. Its `properties`:
|
|
228
|
+
|
|
229
|
+
- `name`: the display name, and the key of the zone in events (`geofence`);
|
|
230
|
+
- any other field of your own (customer, kind, schedule…): events carry them;
|
|
231
|
+
- `enabled` (default true);
|
|
232
|
+
- `onEnter`, `onExit`, `whileInside`, `whileOutside`: one entry per event to emit. The value is an
|
|
233
|
+
endpoint to call with the event, or `""` to emit the event only. Zones without them are
|
|
234
|
+
silent. The `while*` events fire on every location update: avoid them on buckets and endpoints
|
|
235
|
+
unless updates are rare.
|
|
236
|
+
|
|
237
|
+
Every change of the device's `location` (its property, also when a product property or a flow
|
|
238
|
+
writes it, or the location an HTTP device or subdevice sends) is checked against the previous one,
|
|
239
|
+
which is stored, so a server restart does not repeat events. Each transition emits
|
|
240
|
+
`device_geofence_event`, with `device`, `product`, `asset_type`, `asset_group`, `geofence` (name),
|
|
241
|
+
`geofence_id`, `geofence_properties` (the feature's own properties), `geofence_event` (onEnter…),
|
|
242
|
+
`latitude` and `longitude`. When one update leaves a zone and enters another, the exits are
|
|
243
|
+
emitted first. Changing or removing zones emits nothing: a device inside a zone that is removed gets
|
|
244
|
+
no `onExit`.
|
|
245
|
+
|
|
246
|
+
To keep a history, add a product bucket fed by that event:
|
|
247
|
+
`"data": {"source": "event", "event": "device_geofence_event", "payload_type": "source_event"}`.
|
|
248
|
+
To react to it, add a product flow with the same source and, for example, an `endpoint_call` sink, or
|
|
249
|
+
a `property` sink that keeps the last transition on the device. Event sources need
|
|
250
|
+
`"payload_type": "source_event"` in flows too, or nothing reaches the sink:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"name": "Last geofence transition",
|
|
255
|
+
"enabled": true,
|
|
256
|
+
"data": { "source": "event", "event": "device_geofence_event", "payload_type": "source_event" },
|
|
257
|
+
"sink": {
|
|
258
|
+
"target": "property",
|
|
259
|
+
"property": "last_zone",
|
|
260
|
+
"payload_type": "template_payload",
|
|
261
|
+
"payload": "{\"zone\": \"{{payload.geofence_id}}\", \"event\": \"{{payload.geofence_event}}\"}"
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
In a sink template, `{{payload.<field>}}` reads the event and `{{property.<id>.<field>}}` a property
|
|
267
|
+
of the device.
|
|
268
|
+
|
|
269
|
+
## Alarms
|
|
270
|
+
|
|
271
|
+
- An **alarm rule** checks sources every `check_interval` (must be > 0). Each source has a
|
|
272
|
+
`name` and is one of:
|
|
273
|
+
- `bucket`: `{ id, mapping: [fields], group_by: ["device"], aggregation: { period, type } }`.
|
|
274
|
+
`group_by: ["device"]` gives one value per device (needed to raise per-device alarms). The
|
|
275
|
+
period is `<n>m`, `h`, `d`, `w`, `mo` or `y` (defaults to the check interval); types `mean`,
|
|
276
|
+
`min`, `max`, `sum`, `count`, `median`, `spread` (max − min, a simple rate of change)…
|
|
277
|
+
Each field of `mapping` is reduced on its own: a device whose records lack one of them still
|
|
278
|
+
gets the others, and the missing one is absent.
|
|
279
|
+
- `device_property`: `{ property, fields: [...] }`, resolved with inheritance (device → asset
|
|
280
|
+
type → asset group → product). The value must be an object, and **fields are paths in the
|
|
281
|
+
property document**, so a value field is `value.<field>`: `fields: ["value.level"]` is read as
|
|
282
|
+
`battery.value.level`.
|
|
283
|
+
- `device_status`: the device's `enabled`, `connection.active`, `connection.ts` (time of the
|
|
284
|
+
last connect / disconnect, not of the last data).
|
|
285
|
+
- Every source can be filtered by `device`, `product`, `asset_type`, `asset_group`, `project`.
|
|
286
|
+
Only **enabled** devices are evaluated: disabling a device takes it out of its alarms.
|
|
287
|
+
- Sources are **joined per device**: each one yields values per device id, and a device's
|
|
288
|
+
conditions see its own values from every source. A bucket source does so with
|
|
289
|
+
`group_by: ["device"]` over the bucket's `device` **tag**, whose value is the device id.
|
|
290
|
+
Product buckets have it by default; give your own buckets `config.tags: ["device"]` too. It is
|
|
291
|
+
what makes per-device reads and filters efficient, and InfluxDB buckets can only group by tags.
|
|
292
|
+
Without it there is no per-device value to join with property or status sources.
|
|
293
|
+
`test-rule` lists, per device, the values it got and why a device was skipped.
|
|
294
|
+
- Conditions: `{ "field": "<source name>.<field>", "comparison": "above" | "below" | "equal" |
|
|
295
|
+
"not_equal" | "inside" | "outside" | "present" | "absent", "value": "<string>" }` (`min` /
|
|
296
|
+
`max` for inside / outside), joined with `"operator": "and" | "or"`. **Values are strings**
|
|
297
|
+
(`"30"`, not `30`). A field can be an expression: `ts() - status.connection.ts` is the time
|
|
298
|
+
since the last connect / disconnect. A value (or `min` / `max`) can be an expression too, over
|
|
299
|
+
any source of the same device: `"limits.value.max_depth_m"`, `"limits.value.max_depth_m * 0.9"`.
|
|
300
|
+
Booleans compare as 1 / 0. A comparison on a field the device does not have is false
|
|
301
|
+
(`not_equal` included), so `zone1 above 5` or `zone2 above 5` works on devices that only have
|
|
302
|
+
`zone1`; test a field with `present` / `absent`. A device is skipped when it has none of the
|
|
303
|
+
fields the conditions read, or misses a whole source, unless `present` / `absent` are used.
|
|
304
|
+
Examples:
|
|
305
|
+
- threshold: `{ "field": "climate.temperature", "comparison": "above", "value": "30" }`;
|
|
306
|
+
- per-device threshold: a bucket source `well` (`group_by: ["device"]`) plus a
|
|
307
|
+
`device_property` source `limits` (`property: "limits"`, `fields: ["value.max_depth_m"]`),
|
|
308
|
+
condition `well.depth_m` `above` `"limits.value.max_depth_m"`. Put the default limit on the
|
|
309
|
+
product or asset group and override it on the devices that need another: one rule for all;
|
|
310
|
+
- offline for 10 min: `status.connection.active` `equal` `"0"` and
|
|
311
|
+
`ts() - status.connection.ts` `above` `"600000"` (a device that never connected has
|
|
312
|
+
`connection.ts` 0, so it counts as offline from the start); the same with a `timespan`
|
|
313
|
+
confirmation of 10 minutes on `connection.active` `equal` `"0"` alone also works;
|
|
314
|
+
- no data for 10 min: a `device_status` source plus a bucket source with
|
|
315
|
+
`aggregation: { period: "10m", type: "count" }`, condition `<bucket source>.<field>`
|
|
316
|
+
`absent` (a device whose source returns nothing is otherwise skipped).
|
|
317
|
+
- `activation` and `normalization` each have `conditions` and a `confirmation`: `immediate`,
|
|
318
|
+
`sequence` (N checks in a row) or `timespan` (true for a duration). Without normalization
|
|
319
|
+
conditions, the alarm clears when activation is false.
|
|
320
|
+
- It creates one **alarm instance** per device that triggers. List them with
|
|
321
|
+
`GET …/alarms/instances?alarm.rule=<rule>&origin.id=<device>&state=1`: like in any resource
|
|
322
|
+
list, a field of the listed documents (nested ones with dots) filters by exact value (numbers
|
|
323
|
+
as numbers, `true` / `false` also as booleans: `?connection.active=true` for connected devices),
|
|
324
|
+
and a repeated parameter matches any of its values (nested fields and booleans since Thinger
|
|
325
|
+
7.5). Instance `state` values:
|
|
326
|
+
0 none, 1 activated, 2 acknowledged, 3 latched, 4 shelved, 5 cleared. `severity` values:
|
|
327
|
+
0 none, 1 low, 2 medium, 3 high, 4 critical.
|
|
328
|
+
- Notifications on activation, normalization and reminders call **endpoints**:
|
|
329
|
+
`[{ "type": "endpoint", "id": "<endpoint>" }]`. The endpoint receives the alarm instance
|
|
330
|
+
(`name`, `severity`, `state`, `origin.id` = device, `evaluation.last_values`, `notification.type`…).
|
|
331
|
+
- Test a rule, a single source or a notification before saving with
|
|
332
|
+
`POST …/alarms/rules/<r>/test-rule`, `/test-source` and `/test-notification`. `test-rule` and
|
|
333
|
+
`test-source` work before the rule exists (any new id; since Thinger 7.5); `test-source` takes a `check_interval`
|
|
334
|
+
for bucket sources without aggregation (default: the rule's, or 1 minute).
|
|
335
|
+
|
|
336
|
+
## Endpoints, storages, tokens
|
|
337
|
+
|
|
338
|
+
- **Endpoint**: an outgoing action (`email`, `http_request`, templates for third-party
|
|
339
|
+
services). Called from the API, devices, products and alarms.
|
|
340
|
+
- **File storage**: files (product code, firmware, web apps). A `public` storage serves its
|
|
341
|
+
files without authentication: that is how url dashboards are hosted.
|
|
342
|
+
- **Access token**: a long-lived credential with allow/deny permissions per resource type, id
|
|
343
|
+
and action. It can be limited to projects, in which case it must be used with `?project=`.
|
|
344
|
+
|
|
345
|
+
## Dashboards
|
|
346
|
+
|
|
347
|
+
- **Build dashboards as SDK apps**: url dashboards (`source: "url"`) are web apps (Angular,
|
|
348
|
+
React, Vue…) built with the dashboard SDK, the npm package `@thinger-io/dashboard-sdk`
|
|
349
|
+
(reference in its README), uploaded to a file storage and rendered in an iframe. They read
|
|
350
|
+
devices, buckets, properties, alarms and live events, and can act on devices.
|
|
351
|
+
- **Widget dashboards** (made in the console editor) are legacy: do not create new ones.
|
|
352
|
+
- A dashboard lives either in the dashboards collection (standalone, no context) or as the
|
|
353
|
+
`dashboard` property of a device, product, asset type, asset group or project. In the
|
|
354
|
+
second case it opens from that resource's page, and the SDK gets its id in `context`. A
|
|
355
|
+
product dashboard shows up on each of its devices, so one page serves the whole fleet.
|
|
356
|
+
|
|
357
|
+
## Events
|
|
358
|
+
|
|
359
|
+
Real-time notifications over `/v2/users/<u>/events` (the SDK's `events.subscribe`). Examples:
|
|
360
|
+
`device_state_change` (connected, disconnected…), `device_property_update`, `bucket_write`,
|
|
361
|
+
`device_resource_stream`, `device_location_update`, `device_geofence_event` (see Geofences),
|
|
362
|
+
`alarm_instance_activate` / `normalize`, `endpoint_call`, and
|
|
363
|
+
`<type>_create|update|delete` for any resource. Access tokens and project members can only
|
|
364
|
+
subscribe to `device_property_update`, `bucket_write` and `alarm_instance_*`.
|
|
365
|
+
|
|
366
|
+
## Choosing where data comes from
|
|
367
|
+
|
|
368
|
+
| Need | Use |
|
|
369
|
+
|---|---|
|
|
370
|
+
| Current value of a sensor, live | device resource stream (`devices.streamResource`) |
|
|
371
|
+
| Read the device on demand | device resource (`devices.getResource`) |
|
|
372
|
+
| Act on a device | device resource call, or product API (`devices.callResource`, `products.call`) |
|
|
373
|
+
| History, charts, aggregates | bucket (`buckets.read` / `subscribe`; `device: true` for product buckets) |
|
|
374
|
+
| Configuration or state that survives offline | property (`devices.getProperty`, `properties.*`) |
|
|
375
|
+
| Is it online, and since when | `devices.get` → `connection`; `devices.subscribeState` |
|
|
376
|
+
| Where it is | `devices.locations` (maps), the `location` property |
|
|
377
|
+
| Entries into and exits from zones | the `geofences` property and `device_geofence_event` (see Geofences) |
|
|
378
|
+
| Notify someone | endpoint (`endpoints.call`), or an alarm rule with notifications |
|
|
379
|
+
|
|
380
|
+
## Recipes
|
|
381
|
+
|
|
382
|
+
- **Same dashboard for every device of a product**: set it as the product's `dashboard`
|
|
383
|
+
property. Read `Thinger.context.device`, and use product buckets with `device: true`.
|
|
384
|
+
- **Fleet overview with drill-down**: a project or standalone dashboard that lists devices
|
|
385
|
+
(`devices.list({ product })`) and routes to a detail view. One app, several entry points.
|
|
386
|
+
- **Per-site view**: put devices in an asset group per site, and filter with
|
|
387
|
+
`asset_group` / `devices.locations({ group })`.
|
|
388
|
+
- **Data from many devices in one chart**: one product bucket shared by all devices (tagged
|
|
389
|
+
with `device` automatically); read it once and split the records by their `device` field.
|