Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ had changed in them - which is the gap this file exists to close.

### Added

- **Run a command on another node over the air.** `companion.cli` sends a
command from a connected companion to a node it is logged in to, as
MeshCore's remote admin does (`TXT_TYPE_CLI_DATA`). The node's own firmware
runs it and answers the same way, and `companion.state` lists the answers as
`cli_replies`. The companion command line has it as `cmd <contact>
<command>`. A study can now provision a mesh the way an operator does,
rather than over every node's serial port.
- **Script input at an instant of simulated time.** `console.type`,
`console.cli` and the `companion.*` verbs that write to a node take `at_ms`:
the call is held and runs when the run reaches that instant, so a script
Expand Down
2 changes: 1 addition & 1 deletion docs/scripting-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ and a verb added tomorrow is usable today.

### Why two layers rather than one

**260**<!--verbdoc:total--> addressable methods hand-written across two
**261**<!--verbdoc:total--> addressable methods hand-written across two
languages is twice that many things to keep in step. Keeping them in step is what the verb manifest (`docs/verbs.json`) is for: a
hand-written surface that names a verb the tree no longer has is exactly the
drift it catches, where once a tool could call a deleted verb and nothing
Expand Down
11 changes: 6 additions & 5 deletions docs/scripting-verbs.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Every verb, and the call that covers it

The wire underneath [scripting-api.md](scripting-api.md). The store registers
**222**<!--verbdoc:public--> public verbs a script can call and
**38**<!--verbdoc:internal--> internal callbacks it cannot, **260**<!--verbdoc:total-->
**223**<!--verbdoc:public--> public verbs a script can call and
**38**<!--verbdoc:internal--> internal callbacks it cannot, **261**<!--verbdoc:total-->
in total, plus the two the socket answers itself, with what each reads, what
each returns, and which façade call reaches it.

Expand Down Expand Up @@ -77,8 +77,8 @@ Two verbs are **not** in this table:

## What this table shows about the surface

- **222**<!--verbdoc:public--> public verbs, plus **38**<!--verbdoc:internal-->
the workbench keeps to itself, **260**<!--verbdoc:total--> registered in
- **223**<!--verbdoc:public--> public verbs, plus **38**<!--verbdoc:internal-->
the workbench keeps to itself, **261**<!--verbdoc:total--> registered in
total. The façade covers the public verbs, over roughly 60 calls once
objects and properties absorb them.
- **The naming is not regular.** `node.*` and `nodes.*` are both node verbs and
Expand Down Expand Up @@ -240,6 +240,7 @@ Two verbs are **not** in this table:
|---|---|---|---|
| `companion.add_channel` | *a bare string*, `node` string, `index` number, `at_ms` number | `asked_for_channel`, `queued`, `at_ms`, `now_ms` | `node.companion.add_channel(index)` |
| `companion.advert` | *a bare string*, `node` string, `flood` bool, `at_ms` number | `advert`, `flood`, `queued`, `at_ms`, `now_ms` | `node.companion.advert(flood=False)` |
| `companion.cli` | *a bare string*, `node` string, `to` string, `command` string, `at_ms` number | `node`, `to`, `command`, `note`, `queued`, `at_ms`, `now_ms` | `node.companion.remote(to, command)` |
| `companion.configure` | *a bare string*, `node` string, `name` string, `lat` number, `lon` number, `tx_dbm` number, `freq_khz` number, `bw_khz` number, `sf` number, `cr` number, `path_hash` number, `at_ms` number | `set`, `queued`, `at_ms`, `now_ms` | `node.companion.configure(...)` |
| `companion.connect` | *a bare string*, `node` string, `at_ms` number | `connected`, `took_port`, `note`, `queued`, `at_ms`, `now_ms` | `node.companion.connect()` |
| `companion.disconnect` | *a bare string*, `node` string | `disconnected` | `node.companion.disconnect()` |
Expand All @@ -248,7 +249,7 @@ Two verbs are **not** in this table:
| `companion.refresh` | *a bare string*, `node` string, `at_ms` number | `node`, `queued`, `at_ms`, `now_ms` | `node.companion.refresh()` |
| `companion.scope` | *a bare string*, `node` string, `scope` string, `at_ms` number | `node`, `scope`, `queued`, `at_ms`, `now_ms` | `node.companion.scope = name` |
| `companion.send` | *a bare string*, `node` string, `text` string, `channel` number, `path_hash` number, `at_ms` number | `sent`, `channel`, `queued`, `at_ms`, `now_ms` | `node.companion.send(text, channel=, path_hash=)` |
| `companion.state` | *a bare string*, `node` string | `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz` | `node.companion (properties)` |
| `companion.state` | *a bare string*, `node` string | `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`, `cli_replies` | `node.companion (properties)` |

### Serving to real clients

Expand Down
31 changes: 28 additions & 3 deletions docs/verb-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Generated. Run `tools/verbdoc/verbdoc.py` to rewrite it and
`tools/verbdoc/verbdoc.py --check` to fail when it is stale.

The store registers 260 verbs: 222 a script may call and
The store registers 261 verbs: 223 a script may call and
38 the workbench calls on itself, which the socket refuses. Of those,
260 say what they are for and 0 do not yet; the ones that
261 say what they are for and 0 do not yet; the ones that
do not are marked, and what is printed for them is read out of the handler
rather than said by it.

Expand Down Expand Up @@ -2222,6 +2222,31 @@ Not made by the test suite: this call needs more than the two-node headless sess

**Client** `node.companion.advert(flood=False)`

### `companion.cli`

Run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way.

**Takes**

| parameter | type | | what |
|---|---|---|---|
| `node` | string | required, primary | the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected |
| `to` | string | required | the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one |
| `command` | string | required | the command line to run there, as it would be typed at that node's serial console; refused when absent or blank |
| `at_ms` | number | optional | an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced |

**Answers** `node`, `to`, `command`, `note`, `queued`, `at_ms`, `now_ms`. The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login <contact> <password>`). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all.

**Example** - ask a repeater over the air what radio settings it holds

```json
{"id":1,"method":"companion.cli","params":{"command":"get radio","node":"Phone","to":"West Lomond"}}
```

Not made by the test suite: this call needs more than the two-node headless session the runnable examples go to.

**Client** `node.companion.remote(to, command)`

### `companion.configure`

Change a node's own settings the way a phone would, sending only the fields that were asked for, and reporting which ones went.
Expand Down Expand Up @@ -2429,7 +2454,7 @@ Read what a client attached to this node would be showing, which is what the nod
|---|---|---|---|
| `node` | string | required, primary | the connected node; refused when nothing is connected to it |

**Answers** `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`. `contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not.
**Answers** `node`, `contacts`, `messages`, `channels`, `recent`, `name`, `freq_khz`, `cli_replies`. `contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`.

**Example** - ask what the client sees

Expand Down
52 changes: 50 additions & 2 deletions docs/verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,53 @@
"what": "announce to the neighbours only"
}
},
"companion.cli": {
"what": "run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way",
"params": [
{
"name": "node",
"type": "string",
"required": true,
"primary": true,
"what": "the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected"
},
{
"name": "to",
"type": "string",
"required": true,
"what": "the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one"
},
{
"name": "command",
"type": "string",
"required": true,
"what": "the command line to run there, as it would be typed at that node's serial console; refused when absent or blank"
},
{
"name": "at_ms",
"type": "number",
"what": "an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced"
}
],
"returns": [
"node",
"to",
"command",
"note",
"queued",
"at_ms",
"now_ms"
],
"answers": "The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login \u003ccontact\u003e \u003cpassword\u003e`). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all.",
"example": {
"params": {
"command": "get radio",
"node": "Phone",
"to": "West Lomond"
},
"what": "ask a repeater over the air what radio settings it holds"
}
},
"companion.configure": {
"what": "change a node's own settings the way a phone would, sending only the fields that were asked for, and reporting which ones went",
"params": [
Expand Down Expand Up @@ -1009,9 +1056,10 @@
"channels",
"recent",
"name",
"freq_khz"
"freq_khz",
"cli_replies"
],
"answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not.",
"answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`.",
"example": {
"params": {
"node": "West Lomond"
Expand Down
40 changes: 40 additions & 0 deletions internal/app/session/companion.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
package session

import (
"encoding/hex"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -141,9 +142,48 @@ func registerCompanion(st *state.Store, s *Sim) {
out["name"] = c.self.Name
out["freq_khz"] = c.self.FreqKHz
}
// The answers to remote commands, oldest first: the only place a
// script can read what companion.cli got back.
replies := []map[string]any{}
for _, m := range c.messages {
if m.CLI {
replies = append(replies, map[string]any{
"from": hex.EncodeToString(m.SenderKey), "text": m.Text,
})
}
}
out["cli_replies"] = replies
return out, nil
})

// A command for another node to run, over the air. The firmware has done
// this end to end all along - a repeater takes TXT_TYPE_CLI_DATA from a
// logged-in admin and answers in kind - and this only originates it, so
// a study can provision a mesh the way an operator does rather than over
// every node's serial port.
s.handleInput(st, "companion.cli", func(w *state.World, p any) (any, error) {
node, _ := stringField(p, "node")
to, _ := namedField(p, "to")
command, _ := namedField(p, "command")
if strings.TrimSpace(to) == "" || strings.TrimSpace(command) == "" {
return nil, fmt.Errorf("companion.cli needs to and command")
}
c, _, err := s.companionFor(node)
if err != nil {
return nil, err
}
if err := silentCompanion(c, node); err != nil {
return nil, err
}
said, err := s.remoteCommand(node, to, command)
if err != nil {
return nil, err
}
c.note("cmd " + to + ": " + command)
s.publishCompanions(w)
return map[string]any{"node": node, "to": to, "command": command, "note": said}, nil
})

s.handleInput(st, "companion.send", func(w *state.World, p any) (any, error) {
node, _ := stringField(p, "node")
text, _ := namedField(p, "text")
Expand Down
52 changes: 50 additions & 2 deletions internal/app/session/companion.verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,53 @@
"what": "announce to the neighbours only"
}
},
"companion.cli": {
"what": "run a command on another node over the air, as an operator's phone administers a repeater it cannot plug into: the text goes as a remote-admin message (TXT_TYPE_CLI_DATA), the recipient runs it on its own command line, and its answer comes back the same way",
"params": [
{
"name": "node",
"type": "string",
"required": true,
"primary": true,
"what": "the connected companion to send from; refused when nothing is connected to it, or when it has not answered anything since it was connected"
},
{
"name": "to",
"type": "string",
"required": true,
"what": "the node to run it, by contact name or by public key in hex as the companion holds it; refused when the companion holds no such contact and the key is too short to address one"
},
{
"name": "command",
"type": "string",
"required": true,
"what": "the command line to run there, as it would be typed at that node's serial console; refused when absent or blank"
},
{
"name": "at_ms",
"type": "number",
"what": "an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced"
}
],
"returns": [
"node",
"to",
"command",
"note",
"queued",
"at_ms",
"now_ms"
],
"answers": "The recipient runs the command only for a sender logged in to it as admin, so log in first (`console.cli` with `login <contact> <password>`). Nothing is acked: the answer is the reply itself, which arrives later and is read from `companion.state` as `cli_replies`. A command from a sender that is not logged in gets no reply at all.",
"example": {
"params": {
"node": "Phone",
"to": "West Lomond",
"command": "get radio"
},
"what": "ask a repeater over the air what radio settings it holds"
}
},
"companion.connect": {
"what": "claim a node's serial port for the companion protocol and make the same opening a phone makes, so a client's view of the node can be shown or driven",
"params": [
Expand Down Expand Up @@ -284,9 +331,10 @@
"channels",
"recent",
"name",
"freq_khz"
"freq_khz",
"cli_replies"
],
"answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not.",
"answers": "`contacts`, `messages` and `channels` are counts rather than the things themselves, and `recent` is the session's own note lines. `name` and `freq_khz` appear only once the node has answered a device query, so their absence means it has not. `cli_replies` lists the answers to remote commands sent with `companion.cli`, oldest first, each with the key prefix it came `from` and its `text`.",
"example": {
"params": {
"node": "West Lomond"
Expand Down
Loading