farmbot
Version:
Farmbot Javascript client.
530 lines (469 loc) • 16.5 kB
text/typescript
import * as Corpus from "./corpus";
import mqtt, { MqttClient } from "mqtt";
import {
rpcRequest,
coordinate,
uuid as genUuid
} from "./util";
import { Dictionary, Vector3 } from "./interfaces";
import { ReadPin, WritePin, bufferToString } from ".";
import {
FarmBotInternalConfig as Conf,
FarmbotConstructorParams,
generateConfig,
CONFIG_DEFAULTS
} from "./config";
import { ResourceAdapter } from "./resources/resource_adapter";
import { MqttChanName, FbjsEventName, Misc } from "./constants";
import { hasLabel } from "./util/is_celery_script";
import { timestamp } from "./util/time";
import { Priority } from "./util/rpc_request";
type RpcResponse = Promise<Corpus.RpcOk | Corpus.RpcError>;
/*
* Clarification for several terms used:
* * Farmware: Plug-ins for FarmBot OS. Sometimes referred to as `scripts`.
* * Microcontroller: Directly controls and interfaces with motors,
* peripherals, sensors, etc. May be on an Arduino or Farmduino board.
* Mostly referred to as `arduino`, but also `mcu`.
*/
/** Meta data that wraps an event callback */
interface CallbackWrapper {
once: boolean;
event: string;
value: Function;
}
export class Farmbot {
/** Storage area for all event handlers */
private _events: Dictionary<CallbackWrapper[]>;
private config: Conf;
public client?: MqttClient;
public resources: ResourceAdapter;
static VERSION = "15.8.11";
constructor(input: FarmbotConstructorParams) {
this._events = {};
this.config = generateConfig(input);
this.resources = new ResourceAdapter(this, this.config.mqttUsername);
}
/** Get a Farmbot Constructor Parameter. */
getConfig = <U extends keyof Conf>(key: U): Conf[U] => this.config[key];
/** Set a Farmbot Constructor Parameter. */
setConfig = <U extends keyof Conf>(key: U, value: Conf[U]) => {
this.config[key] = value;
}
/**
* Installs a "Farmware" (plugin) onto the bot's SD card.
* URL must point to a valid Farmware manifest JSON document.
*/
installFarmware = (url: string) => {
return this.send(rpcRequest([{ kind: "install_farmware", args: { url } }]));
}
/**
* Checks for updates on a particular Farmware plugin when given the name of
* a Farmware. `updateFarmware("take-photo")`
*/
updateFarmware = (pkg: string) => {
return this.send(rpcRequest([{
kind: "update_farmware",
args: { package: pkg }
}]));
}
/** Uninstall a Farmware plugin. */
removeFarmware = (pkg: string) => {
return this.send(rpcRequest([{
kind: "remove_farmware",
args: {
package: pkg
}
}]));
}
/**
* Installs "Farmware" (plugins) authored by FarmBot, Inc.
* onto the bot's SD card.
*/
installFirstPartyFarmware = () => {
return this.send(rpcRequest([{
kind: "install_first_party_farmware",
args: {}
}]));
}
/**
* Deactivate FarmBot OS completely (shutdown).
* Useful before unplugging the power.
*/
powerOff = () => {
return this.send(rpcRequest([{ kind: "power_off", args: {} }]));
}
/** Restart FarmBot OS. */
reboot = () => {
return this.send(rpcRequest([
{ kind: "reboot", args: { package: "farmbot_os" } }
]));
}
/** Reinitialize the FarmBot microcontroller firmware. */
rebootFirmware = () => {
return this.send(rpcRequest([
{ kind: "reboot", args: { package: "arduino_firmware" } }
]));
}
/** Check for new versions of FarmBot OS.
* Downloads and installs if available. */
checkUpdates = () => {
return this.send(rpcRequest([
{ kind: "check_updates", args: { package: "farmbot_os" } }
]));
}
/** THIS WILL RESET THE SD CARD, deleting all non-factory data!
* Be careful!! */
resetOS = (): void => {
return this.publish(rpcRequest([
{ kind: "factory_reset", args: { package: "farmbot_os" } }
]));
}
/** WARNING: will reset all firmware (hardware) settings! */
resetMCU = () => {
return this.send(rpcRequest([
{ kind: "factory_reset", args: { package: "arduino_firmware" } }
]));
}
flashFirmware = (
/** one of: "arduino"|"express_k10"|"farmduino_k14"|"farmduino" */
firmware_name: string) => {
return this.send(rpcRequest([{
kind: "flash_firmware",
args: {
package: firmware_name
}
}]));
}
/**
* Lock the bot from moving (E-STOP). Turns off peripherals and motors. This
* also will pause running regimens and cause any running sequences to exit.
*/
emergencyLock = () => {
const body: Corpus.RpcRequestBodyItem[] =
[{ kind: "emergency_lock", args: {} }];
const rpc = rpcRequest(body, Priority.HIGHEST);
return this.send(rpc);
}
/** Unlock the bot when the user says it is safe. */
emergencyUnlock = () => {
const body: Corpus.RpcRequestBodyItem[] =
[{ kind: "emergency_unlock", args: {} }];
const rpc = rpcRequest(body, Priority.HIGHEST);
return this.send(rpc);
}
/** Execute a sequence by its ID on the FarmBot API. */
execSequence =
(sequence_id: number, body: Corpus.ParameterApplication[] = []) => {
return this.send(rpcRequest([
{ kind: "execute", args: { sequence_id }, body }
]));
}
/** Run an installed Farmware plugin on the SD Card. */
execScript = (
/** Name of the Farmware. */
label: string,
/** Optional ENV vars to pass the Farmware. */
envVars?: Corpus.Pair[] | undefined) => {
return this.send(rpcRequest([
{ kind: "execute_script", args: { label }, body: envVars }
]));
}
/** Bring a particular axis (or all of them) to position 0 in Z Y X order. */
home = (args: { speed: number, axis: Corpus.ALLOWED_AXIS }) => {
return this.send(rpcRequest([{ kind: "home", args }]));
}
/** Use end stops or encoders to figure out where 0,0,0 is in Z Y X axis
* order. WON'T WORK WITHOUT ENCODERS OR END STOPS! A blockage or stall
* during this command will set that position as zero. Use carefully. */
findHome = (args: { speed: number, axis: Corpus.ALLOWED_AXIS }) => {
return this.send(rpcRequest([{ kind: "find_home", args }]));
}
/** Move FarmBot to an absolute point. */
moveAbsolute = (args: Vector3 & { speed?: number }) => {
const { x, y, z } = args;
const speed = args.speed || CONFIG_DEFAULTS.speed;
return this.send(rpcRequest([
{
kind: "move_absolute",
args: {
location: coordinate(x, y, z),
offset: coordinate(0, 0, 0),
speed
}
}
]));
}
/** Move FarmBot to position relative to its current position. */
moveRelative = (args: Vector3 & { speed?: number }) => {
const { x, y, z } = args;
const speed = args.speed || CONFIG_DEFAULTS.speed;
return this.send(rpcRequest([
{ kind: "move_relative", args: { x, y, z, speed } }
]));
}
/** Set a GPIO pin to a particular value. */
writePin = (args: WritePin["args"]) => {
return this.send(rpcRequest([{ kind: "write_pin", args }]));
}
/** Read the value of a GPIO pin. Will create a SensorReading if it's
* a sensor. */
readPin = (args: ReadPin["args"]) => {
return this.send(rpcRequest([{ kind: "read_pin", args }]));
}
/** Reverse the value of a digital pin. */
togglePin = (args: { pin_number: number; }) => {
return this.send(rpcRequest([{ kind: "toggle_pin", args }]));
}
/** Read the status of the bot. Should not be needed unless you are first
* logging in to the device, since the device pushes new states out on
* every update. */
readStatus = (args = {}) => {
return this.send(rpcRequest([{ kind: "read_status", args }]));
}
/** Snap a photo and send to the API for post processing. */
takePhoto =
(args = {}) => this.send(rpcRequest([{ kind: "take_photo", args }]))
/** Download/apply all of the latest FarmBot API JSON resources (plants,
* account info, etc.) to the device. */
sync = (args = {}) => this.send(rpcRequest([{ kind: "sync", args }]));
/**
* Set the current position of the given axis to 0.
* Example: Sending `bot.setZero("x")` at x: 255 will translate position
* 255 to 0, causing that position to be x: 0.
*/
setZero = (axis: Corpus.ALLOWED_AXIS) => {
return this.send(rpcRequest([{
kind: "zero",
args: { axis }
}]));
}
/**
* Set user ENV vars (usually used by 3rd-party Farmware plugins).
* Set value to `undefined` to unset.
*/
setUserEnv = (configs: Dictionary<(string | undefined)>) => {
const body = Object
.keys(configs)
.map(function (label): Corpus.Pair {
return {
kind: "pair",
args: { label, value: (configs[label] || Misc.NULL) }
};
});
return this.send(rpcRequest([{ kind: "set_user_env", args: {}, body }]));
}
sendMessage = (message_type: Corpus.ALLOWED_MESSAGE_TYPES,
message: string,
channels: Corpus.ALLOWED_CHANNEL_NAMES[] = []) => {
this.send(rpcRequest([{
kind: "send_message",
args: {
message_type,
message
},
body: channels.map(channel_name => ({
kind: "channel",
args: {
channel_name
}
}))
}]));
}
/** Control servos on pins 4 and 5. */
setServoAngle = (args: { pin_number: number; pin_value: number; }) => {
const result = this.send(rpcRequest([{ kind: "set_servo_angle", args }]));
// Celery script can't validate `pin_number` and `pin_value` the way we need
// for `set_servo_angle`. We will send the RPC command off, but also
// crash the client to aid debugging.
if (![4, 5, 6, 11].includes(args.pin_number)) {
throw new Error("Servos only work on pins 4 and 5");
}
if (args.pin_value > 180 || args.pin_value < 0) {
throw new Error("Pin value outside of 0...180 range");
}
return result;
}
/**
* Find the axis extents using encoder, motor, or end-stop feedback.
* Will set a new home position and a new axis length for the given axis.
*/
calibrate = (args: { axis: Corpus.ALLOWED_AXIS }) => {
return this.send(rpcRequest([{ kind: "calibrate", args }]));
}
lua = (lua: string) => {
return this.send(rpcRequest([
{ kind: "lua", args: { lua } }
]));
}
/**
* Retrieves all of the event handlers for a particular event.
* Returns an empty array if the event did not exist.
*/
event = (name: string) => {
this._events[name] = this._events[name] || [];
return this._events[name];
}
on = (event: string, value: Function, once = false) => {
this.event(event).push({ value, once, event });
}
emit = (event: string, data: {}) => {
const nextArray: CallbackWrapper[] = [];
this.event(event)
.concat(this.event("*"))
.forEach(function (handler) {
try {
handler.value(data, event);
if (!handler.once && handler.event === event) {
nextArray.push(handler);
}
} catch (e) {
const msg = `Exception thrown while handling '${event}' event.`;
console.warn(msg);
console.dir(e);
}
});
if (nextArray.length === 0) {
delete this._events[event];
} else {
this._events[event] = nextArray;
}
}
/** Dictionary of all relevant MQTT channels the bot uses. */
get channel() {
const deviceName = this.config.mqttUsername;
return {
/** From the browser, usually. */
toDevice: `bot/${deviceName}/${MqttChanName.fromClients}`,
/** From farmbot */
toClient: `bot/${deviceName}/${MqttChanName.fromDevice}`,
status: `bot/${deviceName}/${MqttChanName.status}`,
logs: `bot/${deviceName}/${MqttChanName.logs}`,
sync: `bot/${deviceName}/${MqttChanName.sync}/#`,
/** Read only */
pong: `bot/${deviceName}/pong/#`,
/** Write only: bot/${deviceName}/ping/${timestamp} */
ping: (tStamp: number) => `bot/${deviceName}/ping/${tStamp}`
};
}
/** Low-level means of sending MQTT packets. Does not check format. Does not
* acknowledge confirmation. Probably not the one you want. */
publish = (msg: Corpus.RpcRequest, important = true): void => {
if (this.client) {
this.emit(FbjsEventName.sent, msg);
/** SEE: https://github.com/mqttjs/MQTT.js#client */
this.client.publish(this.channel.toDevice, JSON.stringify(msg));
} else {
if (important) {
throw new Error("Not connected to server");
}
}
}
/** Low-level means of sending MQTT RPC commands to the bot. Acknowledges
* receipt of message, but does not check formatting. Consider using higher
* level methods like .moveRelative(), .calibrate(), etc....
*/
send = (input: Corpus.RpcRequest): RpcResponse => {
return new Promise((resolve, reject) => {
this.publish(input);
function handler(response: Corpus.RpcOk | Corpus.RpcError) {
switch (response.kind) {
case "rpc_ok": return resolve(response);
case "rpc_error":
const reason = (response.body || [])
.map(x => x.args.message)
.join(", ");
return reject(new Error(reason));
default:
console.dir(response);
throw new Error("Got a bad CeleryScript node.");
}
}
this.on(input.args.label, handler, true);
});
}
/** Main entry point for all MQTT packets. */
_onmessage = (chan: string, buffer: Uint8Array) => {
const original = bufferToString(buffer);
const segments = chan.split(Misc.MQTT_DELIM);
const { emit } = this;
try {
const msg = JSON.parse(original);
if (segments[0] == MqttChanName.publicBroadcast) {
return emit(MqttChanName.publicBroadcast, msg);
}
switch (segments[2]) {
case MqttChanName.logs: return emit(FbjsEventName.logs, msg);
case MqttChanName.status: return emit(FbjsEventName.status, msg);
case MqttChanName.sync: return emit(FbjsEventName.sync, msg);
case MqttChanName.pong:
return emit(segments[3], msg);
default:
const ev = hasLabel(msg) ? msg.args.label : FbjsEventName.malformed;
return emit(ev, msg);
}
} catch (error) {
console
.dir({ text: "Could not parse inbound message from MQTT.", error });
emit(FbjsEventName.malformed, original);
}
}
ping = (timeout = 10000, now = timestamp()): Promise<number> => {
this.setConfig("LAST_PING_OUT", now);
return this.doPing(now, timeout);
}
// STEP 0: Subscribe to `bot/device_23/pong/#`
// STEP 0: Send `bot/device_23/ping/3123123`
// STEP 0: Receive `bot/device_23/pong/3123123`
private doPing = (startedAt: number, timeout: number): Promise<number> => {
const timeoutPromise =
new Promise<number>((_, rej) => setTimeout(() => rej(-0), timeout));
const pingPromise = new Promise<number>((res, _) => {
const ok = () => {
const t = timestamp();
this.setConfig("LAST_PING_IN", t);
res(t - startedAt);
};
this.on("" + startedAt, ok, true);
const chan = this.channel.ping(startedAt);
if (this.client) {
this.client.publish(chan, JSON.stringify(startedAt));
}
});
return Promise.race([timeoutPromise, pingPromise]);
}
/** Bootstrap the device onto the MQTT broker. */
connect = () => {
const { mqttUsername, token, mqttServer } = this.config;
const reconnectPeriod: number = Misc.RECONNECT_THROTTLE_MS;
const client = mqtt.connect(mqttServer, {
clean: true,
clientId: `FBJS-${Farmbot.VERSION}-${genUuid()}`,
password: token,
protocolId: "MQTT",
protocolVersion: 4,
reconnectPeriod,
username: mqttUsername,
});
this.client = client;
this.resources = new ResourceAdapter(this, this.config.mqttUsername);
client.on("message", this._onmessage);
client.on("offline", () => this.emit(FbjsEventName.offline, {}));
client.on("connect", () => this.emit(FbjsEventName.online, {}));
const channels = [
this.channel.logs,
this.channel.status,
this.channel.sync,
this.channel.toClient,
this.channel.pong
];
client.subscribe(channels);
return new Promise((resolve, _reject) => {
if (this.client) {
this.client.once("connect", () => resolve(this));
} else {
throw new Error("Please connect first.");
}
});
}
}