Merge conflict bloopers
commit
33150772e6
|
@ -25,3 +25,4 @@ public/webpack/*
|
|||
tmp
|
||||
public/direct_upload/temp/*.jpg
|
||||
scratchpad.rb
|
||||
*scratchpad*
|
||||
|
|
7
Procfile
7
Procfile
|
@ -1,3 +1,4 @@
|
|||
web: bundle exec passenger start -p $PORT -e $RAILS_ENV --max-pool-size 3
|
||||
log_service: bin/rails r lib/log_service.rb
|
||||
worker: bundle exec rake jobs:work
|
||||
background_jobs: bundle exec rake jobs:work
|
||||
log_worker: bin/rails r lib/log_service_runner.rb
|
||||
resource_worker: bin/rails r lib/resource_service_runner.rb
|
||||
web: bundle exec passenger start -p $PORT -e $RAILS_ENV --max-pool-size 3
|
||||
|
|
10
Procfile.dev
10
Procfile.dev
|
@ -1,9 +1,9 @@
|
|||
# Run Rails & Webpack concurrently
|
||||
rails: rails s -e development -p ${API_PORT:-3000} -b 0.0.0.0
|
||||
webpack: ./node_modules/.bin/webpack-dev-server --config config/webpack.config.js
|
||||
worker1: rake jobs:work
|
||||
worker2: rake jobs:work
|
||||
logger: rails r lib/log_service.rb
|
||||
rails: rails s -e development -p ${API_PORT:-3000} -b 0.0.0.0
|
||||
log_service: rails r lib/log_service_runner.rb
|
||||
resource_service: rails r lib/resource_service_runner.rb
|
||||
webpack: ./node_modules/.bin/webpack-dev-server --config config/webpack.config.js
|
||||
worker: rake jobs:work
|
||||
|
||||
# UNCOMMENT THIS LINE IF YOU ARE DOING MOBILE TESTING:
|
||||
# Get started with `npm install weinre -g`
|
||||
|
|
|
@ -14,8 +14,7 @@ module Api
|
|||
end
|
||||
|
||||
def update
|
||||
mutate Configs::Update
|
||||
.run(target: config_object, update_attrs: raw_json)
|
||||
mutate Configs::Update.run(target: config_object, update_attrs: raw_json)
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
|
@ -7,8 +7,7 @@ module Api
|
|||
class RmqUtilsController < Api::AbstractController
|
||||
# The only valid format for AMQP / MQTT topics.
|
||||
# Prevents a whole host of abuse / security issues.
|
||||
TOPIC_REGEX = \
|
||||
/(bot\.device_)\d*\.(from_clients|from_device|logs|status|sync)\.?.*/
|
||||
TOPIC_REGEX = /(bot\.device_)\d*\.(from_clients|from_device|logs|status|sync|resources_v0|from_api)\.?.*/
|
||||
MALFORMED_TOPIC = "malformed topic. Must match #{TOPIC_REGEX.inspect}"
|
||||
ALL = [:user, :vhost, :resource, :topic]
|
||||
VHOST = ENV.fetch("MQTT_VHOST") { "/" }
|
||||
|
|
|
@ -23,7 +23,7 @@ module Api
|
|||
end
|
||||
|
||||
def destroy
|
||||
mutate Sequences::Delete.run(sequence: sequence, device: current_device)
|
||||
mutate Sequences::Destroy.run(sequence: sequence, device: current_device)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
module Resources
|
||||
DEVICE_REGEX = /device_\d*/
|
||||
ACTIONS = [
|
||||
DESTROY = "destroy"
|
||||
]
|
||||
RESOURCES = { # Because I don't trust Kernel.const_get
|
||||
"FarmEvent" => FarmEvent,
|
||||
"FarmwareInstallations" => FarmwareInstallation,
|
||||
"Image" => Image,
|
||||
"Log" => Log,
|
||||
"Peripheral" => Peripheral,
|
||||
"PinBinding" => PinBinding,
|
||||
"PlantTemplate" => PlantTemplate,
|
||||
"Point" => Point,
|
||||
"Regimen" => Regimen,
|
||||
"SavedGarden" => SavedGarden,
|
||||
"Sensor" => Sensor,
|
||||
"SensorReading" => SensorReading,
|
||||
"Sequence" => Sequence,
|
||||
"Tool" => Tool,
|
||||
"WebcamFeed" => WebcamFeed,
|
||||
}
|
||||
end # Resources
|
|
@ -0,0 +1,48 @@
|
|||
module Resources
|
||||
class Job < Mutations::Command
|
||||
NOT_FOUND = "Resource not found"
|
||||
required do
|
||||
duck :body, methods: [:[], :[]=]
|
||||
duck :resource, duck: [:where, :find_by]
|
||||
integer :resource_id
|
||||
model :device, class: Device
|
||||
string :action, in: ACTIONS
|
||||
string :uuid
|
||||
end
|
||||
|
||||
def validate
|
||||
# Should never trigger in production.
|
||||
never unless RESOURCES.values.include?(resource) # Security critical
|
||||
end
|
||||
|
||||
def execute
|
||||
case action
|
||||
when DESTROY then do_deletion
|
||||
else; never
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def plural_resource
|
||||
@plural_resource ||= resource.name.pluralize
|
||||
end
|
||||
|
||||
def do_deletion
|
||||
model_name = resource.model_name
|
||||
mutation = Kernel.const_get(model_name.name.pluralize)::Destroy
|
||||
mutation.run!(model_name.singular => model, device: device)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
add_error :resource, :resource, NOT_FOUND
|
||||
end
|
||||
|
||||
def model
|
||||
@model ||= device.send(plural_resource.tableize).find(resource_id)
|
||||
end
|
||||
|
||||
# Escape hatch for things that should "never happen".
|
||||
def never
|
||||
raise "PANIC: Tried to do batch op on #{resource}"
|
||||
end
|
||||
end # Job
|
||||
end # Resources
|
|
@ -0,0 +1,68 @@
|
|||
module Resources
|
||||
# Takes a bunch of unsafe, string-y data that came in over AMQP and parses it
|
||||
# into fully formed
|
||||
class PreProcessor < Mutations::Command
|
||||
def self.from_amqp(delivery_info, body)
|
||||
# Parse the AMQP rotuing key into an Array of strings.
|
||||
# A properly formatted routing_key will look like this after processing:
|
||||
#
|
||||
# ["bot", "device_3", "resources_v0", "destroy", "Sequence", "2", "xyz"]
|
||||
segments = delivery_info.routing_key.split(".")
|
||||
_, device_name, _, action, resource, resource_id, uuid = segments
|
||||
run!(device_name: device_name,
|
||||
action: action,
|
||||
resource: resource,
|
||||
resource_id: resource_id,
|
||||
uuid: uuid,
|
||||
body: body.empty? ? "{}" : body)
|
||||
end
|
||||
|
||||
required do
|
||||
string :action, in: ACTIONS # "destroy"
|
||||
string :device_name, matches: DEVICE_REGEX # "device_3"
|
||||
string :resource, in: RESOURCES.keys # "Sequence"
|
||||
end
|
||||
|
||||
optional do
|
||||
integer :resource_id, default: 0 # 2
|
||||
string :body # "{\"json\":true}"
|
||||
string :uuid, default: "NONE" # "0dce-1d-41-1d-e95c3b"
|
||||
end
|
||||
|
||||
def validate
|
||||
maybe_set_device
|
||||
maybe_set_body
|
||||
end
|
||||
|
||||
def execute
|
||||
{
|
||||
action: action,
|
||||
device: @device,
|
||||
body: @body,
|
||||
resource_id: resource_id,
|
||||
resource: RESOURCES.fetch(resource),
|
||||
uuid: uuid,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fail_body
|
||||
add_error :body, :body, "body must be a JSON object"
|
||||
end
|
||||
|
||||
def maybe_set_body
|
||||
hash = JSON.parse(body)
|
||||
fail_body unless hash.is_a?(Hash)
|
||||
@body = hash
|
||||
rescue JSON::ParserError
|
||||
fail_body
|
||||
end
|
||||
|
||||
def maybe_set_device
|
||||
id = device_name.gsub("device_", "").to_i
|
||||
@device = Device.find_by(id: id)
|
||||
add_error :device, :device, "Can't find device ##{id}" unless @device
|
||||
end
|
||||
end # PreProcessor
|
||||
end # Resources
|
|
@ -0,0 +1,32 @@
|
|||
module Resources
|
||||
class Service
|
||||
def self.process(delivery_info, body)
|
||||
params = PreProcessor.from_amqp(delivery_info, body)
|
||||
puts params if Rails.env.production?
|
||||
result = Job.run!(params)
|
||||
payl = result ? result.to_json : ""
|
||||
chan = ["from_api", (params[:uuid] || "NONE")].join(".")
|
||||
params[:device].auto_sync_transaction do
|
||||
Transport.current.amqp_send(payl, params[:device].id, chan)
|
||||
end
|
||||
rescue Mutations::ValidationException => q
|
||||
Rollbar.error(q)
|
||||
params ||= {}
|
||||
raw_chan = delivery_info&.routing_key&.split(".") || []
|
||||
device = params[:device]
|
||||
device_id = device ? device.id : raw_chan[1]&.gsub("device_", "")&.to_i
|
||||
if device_id
|
||||
message = {
|
||||
kind: "rpc_error",
|
||||
args: { label: params[:uuid] || raw_chan[6] || "NONE" },
|
||||
body: (q
|
||||
.errors
|
||||
.values
|
||||
.map { |err| { kind: "explanation", args: { message: err.message }} })
|
||||
}.to_json
|
||||
chan = ["from_api", (raw_chan.last || "")].join(".")
|
||||
Transport.current.amqp_send(message, device_id, chan)
|
||||
end
|
||||
end
|
||||
end # Service
|
||||
end # Resources
|
|
@ -0,0 +1,23 @@
|
|||
class ServiceRunner
|
||||
WAIT_TIME = Rails.env.test? ? 0.01 : 5
|
||||
|
||||
def self.go!(channel, worker_klass)
|
||||
self.new(channel, worker_klass).run!
|
||||
end
|
||||
|
||||
def initialize(channel, worker_klass)
|
||||
@channel = channel
|
||||
@worker = worker_klass
|
||||
end
|
||||
|
||||
def run!
|
||||
@channel.subscribe(block: true) do |info, _, payl|
|
||||
@worker.process(info, payl.force_encoding("UTF-8"))
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e)
|
||||
puts "MQTT Broker is unreachable. Waiting 5 seconds..."
|
||||
sleep WAIT_TIME
|
||||
retry
|
||||
end
|
||||
end
|
|
@ -79,7 +79,7 @@ class Device < ApplicationRecord
|
|||
points.where(pointer_type: "Plant")
|
||||
end
|
||||
|
||||
TIMEOUT = (Rails.env.test? ? 0.001 : 150).seconds
|
||||
TIMEOUT = 150.seconds
|
||||
|
||||
# Like Device.find, but with 150 seconds of caching to avoid DB calls.
|
||||
def self.cached_find(id)
|
||||
|
@ -145,6 +145,10 @@ class Device < ApplicationRecord
|
|||
tell(message, channels , type).save
|
||||
end
|
||||
|
||||
def regimina
|
||||
regimens # :(
|
||||
end
|
||||
|
||||
# CONTEXT:
|
||||
# * We tried to use Rails low level caching, but it hit marshalling issues.
|
||||
# * We did a hack with Device.new(self.as_json) to get around it.
|
||||
|
|
|
@ -41,6 +41,13 @@ class Transport
|
|||
.bind("amq.topic", routing_key: "bot.*.logs")
|
||||
end
|
||||
|
||||
def resource_channel
|
||||
@resource_channel ||= self.connection
|
||||
.create_channel
|
||||
.queue("resource_workers")
|
||||
.bind("amq.topic", routing_key: "bot.*.resources_v0.#")
|
||||
end
|
||||
|
||||
def amqp_topic
|
||||
@amqp_topic ||= self
|
||||
.connection
|
||||
|
@ -49,7 +56,10 @@ class Transport
|
|||
end
|
||||
|
||||
def amqp_send(message, id, channel)
|
||||
amqp_topic.publish(message, routing_key: "bot.device_#{id}.#{channel}")
|
||||
raise "BAD `id`" unless id.is_a?(String) || id.is_a?(Integer)
|
||||
routing_key = "bot.device_#{id}.#{channel}"
|
||||
puts message if Rails.env.production?
|
||||
amqp_topic.publish(message, routing_key: routing_key)
|
||||
end
|
||||
|
||||
# We need to hoist the Rack X-Farmbot-Rpc-Id to a global state so that it can
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# I heard you like mutations. So we made a mutation class that creates mutations
|
||||
# so you can mutate while you mutate.
|
||||
# This class will create a "base case" `::Destroy` mutation. Very useful when
|
||||
# you don't have special logic in your ::Destroy class and just need a base case
|
||||
class CreateDestroyer < Mutations::Command
|
||||
BAD_OWNERSHIP = "You do not own that %s"
|
||||
|
||||
required { duck :resource }
|
||||
|
||||
def execute
|
||||
klass = Class.new(Mutations::Command)
|
||||
|
||||
klass.instance_variable_set("@resource", resource)
|
||||
|
||||
klass.class_eval do |x|
|
||||
def self.resource
|
||||
@resource
|
||||
end
|
||||
|
||||
def self.resource_name
|
||||
resource.model_name.singular
|
||||
end
|
||||
|
||||
def resource_name
|
||||
self.class.resource_name.to_sym
|
||||
end
|
||||
|
||||
required do
|
||||
model :device, class: Device
|
||||
model x.resource_name, class: x.resource
|
||||
end
|
||||
|
||||
def validate
|
||||
not_yours unless self.send(resource_name).device == device
|
||||
end
|
||||
|
||||
def execute
|
||||
self.send(resource_name).destroy! && ""
|
||||
end
|
||||
|
||||
def not_yours
|
||||
add_error resource_name, resource_name, BAD_OWNERSHIP % resource_name
|
||||
end
|
||||
end
|
||||
|
||||
return klass
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module DiagnosticDumps
|
||||
Destroy = CreateDestroyer.run!(resource: DiagnosticDump)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module FarmEvents
|
||||
Destroy = CreateDestroyer.run!(resource: FarmEvent)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module FarmwareInstallations
|
||||
Destroy = CreateDestroyer.run!(resource: FarmwareInstallation)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module Images
|
||||
Destroy = CreateDestroyer.run!(resource: Image)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module Logs
|
||||
Destroy = CreateDestroyer.run!(resource: Log)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module PlantTemplates
|
||||
Destroy = CreateDestroyer.run!(resource: PlantTemplate)
|
||||
end
|
|
@ -7,15 +7,19 @@ module Points
|
|||
|
||||
required do
|
||||
model :device, class: Device
|
||||
array :point_ids, class: Integer
|
||||
end
|
||||
|
||||
optional { boolean :hard_delete, default: false }
|
||||
optional do
|
||||
boolean :hard_delete, default: false
|
||||
array :point_ids, class: Integer
|
||||
model :point, class: Point
|
||||
end
|
||||
|
||||
P = :point
|
||||
S = :sequence
|
||||
|
||||
def validate
|
||||
maybe_wrap_ids
|
||||
# Collect names of sequences that still use this point.
|
||||
problems = (tool_seq + point_seq)
|
||||
.group_by(&:sequence_name)
|
||||
|
@ -88,5 +92,10 @@ module Points
|
|||
.where(tool_id: every_tool_id_as_json, device_id: device.id)
|
||||
.to_a
|
||||
end
|
||||
|
||||
def maybe_wrap_ids
|
||||
raise "NO" unless (point || point_ids)
|
||||
inputs[:point_ids] = [point.id] if point
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,3 +20,4 @@ module Regimens
|
|||
end
|
||||
end
|
||||
end
|
||||
Regimina ||= Regimens # Lol, inflection errors
|
||||
|
|
|
@ -17,3 +17,5 @@ module Regimens
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Regimina ||= Regimens # Lol, inflection errors
|
||||
|
|
|
@ -32,3 +32,4 @@ module Regimens
|
|||
end
|
||||
end
|
||||
end
|
||||
Regimina ||= Regimens # Lol, inflection errors
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
module SavedGardens
|
||||
Destroy = CreateDestroyer.run!(resource: SavedGarden)
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
module SensorReadings
|
||||
Destroy = CreateDestroyer.run!(resource: SensorReading)
|
||||
end
|
|
@ -1,5 +1,5 @@
|
|||
module Sequences
|
||||
class Delete < Mutations::Command
|
||||
class Destroy < Mutations::Command
|
||||
IN_USE = "Sequence is still in use by"
|
||||
THE_FOLLOWING = " the following %{resource}: %{items}"
|
||||
AND = " and"
|
|
@ -0,0 +1,4 @@
|
|||
module WebcamFeeds
|
||||
Destroy = CreateDestroyer.run!(resource: WebcamFeed,
|
||||
singular_name: "webcam_feed")
|
||||
end
|
|
@ -0,0 +1,78 @@
|
|||
# Support Table
|
||||
|
||||
Not all resources support the experimental resource API.
|
||||
|
||||
|Resource | Delete | Update / Insert|
|
||||
|-----------------------|---------|----------------|
|
||||
| FarmEvent | :heart: | :broken_heart: |
|
||||
| FarmwareInstallation | :heart: | :broken_heart: |
|
||||
| Image | :heart: | :broken_heart: |
|
||||
| Log | :heart: | :broken_heart: |
|
||||
| Peripheral | :heart: | :broken_heart: |
|
||||
| PinBinding | :heart: | :broken_heart: |
|
||||
| PlantTemplate | :heart: | :broken_heart: |
|
||||
| Point | :heart: | :broken_heart: |
|
||||
| Regimen | :heart: | :broken_heart: |
|
||||
| SavedGarden | :heart: | :broken_heart: |
|
||||
| Sensor | :heart: | :broken_heart: |
|
||||
| SensorReading | :heart: | :broken_heart: |
|
||||
| Sequence | :heart: | :broken_heart: |
|
||||
| Tool | :heart: | :broken_heart: |
|
||||
| WebcamFeed | :heart: | :broken_heart: |
|
||||
|
||||
# Step 1: Send the Update
|
||||
|
||||
Send an MQTT message in the format of:
|
||||
|
||||
```
|
||||
bot/device_<id>/resources_v0/<action>/<resource type>/<resource_id or 0>/<Transaction UUID>
|
||||
```
|
||||
|
||||
Example 1-1:
|
||||
|
||||
```
|
||||
bot/device_3/resources_v0/destroy/Sequence/2/123-456
|
||||
```
|
||||
|
||||
NOTES:
|
||||
|
||||
* `<Transaction UUID>` can be any user defined string. Ensure that the string is unique. We recommend using UUIDs.
|
||||
* `<resource_id>` This is the `.id` property of the resource you are deleting.
|
||||
* `<action>` Only `destroy` is supported as of July 2018.
|
||||
* `<resource type>` See "resource" column of table above. **Case sensitive**.
|
||||
|
||||
**For deletion messages** the body of the message is unimportant and is discarded by the server.
|
||||
|
||||
# Step 2(B): Handle Success
|
||||
|
||||
The results will be streamed to the following MQTT channel:
|
||||
|
||||
```
|
||||
bot/device_<id>/from_api/<Transaction UUID>
|
||||
```
|
||||
|
||||
**The response will vary depending on the type of resource and action.**
|
||||
|
||||
|
||||
# Step 2(B): Handle Failure
|
||||
|
||||
If your message is malformed or the server was unable to complete the request, you will receive an error message on the following MQTT channel:
|
||||
|
||||
```
|
||||
bot/device_<id>/from_api/<Transaction UUID>
|
||||
```
|
||||
|
||||
The message will take the same format as RPC errors:
|
||||
|
||||
```
|
||||
{
|
||||
"kind": "rpc_error",
|
||||
"args": { "label": "THE UUID YOU GAVE THE SERVER" },
|
||||
"body": [
|
||||
{
|
||||
"kind": "explanation",
|
||||
"args": { "message": "Human readable explanation message" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
|
@ -1,16 +0,0 @@
|
|||
require_relative "./log_service_support"
|
||||
|
||||
begin
|
||||
# Listen to all logs on the message broker and store them in the database.
|
||||
Transport
|
||||
.current
|
||||
.log_channel
|
||||
.subscribe(block: true) do |info, _, payl|
|
||||
LogService.process(info, payl.force_encoding("UTF-8"))
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e)
|
||||
puts "MQTT Broker is unreachable. Waiting 5 seconds..."
|
||||
sleep 5
|
||||
retry
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
require_relative "../app/lib/service_runner_base.rb"
|
||||
|
||||
ServiceRunner.go!(Transport.current.log_channel, LogService)
|
|
@ -0,0 +1,7 @@
|
|||
require_relative "../app/lib/service_runner_base.rb"
|
||||
require_relative "../app/lib/resources.rb"
|
||||
require_relative "../app/lib/resources/preprocessor.rb"
|
||||
require_relative "../app/lib/resources/job.rb"
|
||||
require_relative "../app/lib/resources/service.rb"
|
||||
|
||||
ServiceRunner.go!(Transport.current.resource_channel, Resources::Service)
|
|
@ -1,5 +0,0 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe CleanOutOldDbItemsJob, type: :job do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
|
@ -36,6 +36,4 @@ describe AmqpLogParser do
|
|||
expect(data.valid?).to be(false)
|
||||
expect(data.problems).to include AmqpLogParser::TOO_OLD
|
||||
end
|
||||
|
||||
it "passes all other logs"
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
require "spec_helper"
|
||||
require_relative "../../lib/log_service_support"
|
||||
# require_relative "../../lib/log_service"
|
||||
|
||||
describe LogService do
|
||||
normal_payl = '{"meta":{"z":0,"y":0,"x":0,"type":"info","major_version":6},' +
|
||||
|
@ -29,15 +29,23 @@ describe LogService do
|
|||
|
||||
it "calls .subscribe() on Transport." do
|
||||
Transport.current.clear!
|
||||
load "lib/log_service.rb"
|
||||
load "./lib/log_service_runner.rb"
|
||||
arg1 = Transport.current.connection.calls[:subscribe].last[0]
|
||||
routing_key = Transport.current.connection.calls[:bind].last[1][:routing_key]
|
||||
expect(arg1).to eq({block: true})
|
||||
expect(routing_key).to eq("bot.*.logs")
|
||||
end
|
||||
|
||||
it "calls .subscribe() on Transport." do
|
||||
Transport.current.clear!
|
||||
load "./lib/resource_service_runner.rb"
|
||||
arg1 = Transport.current.connection.calls[:subscribe].last[0]
|
||||
routing_key = Transport.current.connection.calls[:bind].last[1][:routing_key]
|
||||
expect(arg1).to eq({block: true})
|
||||
expect(routing_key).to eq("bot.*.resources_v0.#")
|
||||
end
|
||||
|
||||
it "creates new messages in the DB when called" do
|
||||
puts "Blinky test"
|
||||
Log.destroy_all
|
||||
b4 = Log.count
|
||||
LogService.process(fake_delivery_info, normal_payl)
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe Resources::Job do
|
||||
base = { body: {}, action: "destroy", uuid: SecureRandom.uuid }
|
||||
|
||||
it "executes deletion for various resources" do
|
||||
puts "TODO: Test cases for points, sequences."
|
||||
device = FactoryBot.create(:device)
|
||||
test_cases = [
|
||||
FarmEvent,
|
||||
FarmwareInstallation,
|
||||
Image,
|
||||
Log,
|
||||
Peripheral,
|
||||
PinBinding,
|
||||
PlantTemplate,
|
||||
Regimen,
|
||||
SavedGarden,
|
||||
Sensor,
|
||||
SensorReading,
|
||||
WebcamFeed,
|
||||
]
|
||||
.each{ |k| k.delete_all }
|
||||
.map { |k| FactoryBot.create(k.model_name.singular.to_sym, device: device) }
|
||||
.concat([FakeSequence.create( device: device)])
|
||||
.map do |r|
|
||||
base.merge({resource: r.class, resource_id: r.id, device: device})
|
||||
end
|
||||
.map do |params|
|
||||
res = params[:resource]
|
||||
count = res.count
|
||||
Resources::Job.run!(params)
|
||||
expect(res.count).to eq(count - 1)
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't let you delete other people's resources" do
|
||||
device_a = FactoryBot.create(:device)
|
||||
device_b = FactoryBot.create(:device)
|
||||
farm_event = FactoryBot.create(:farm_event, device: device_b)
|
||||
params = base.merge(resource: FarmEvent,
|
||||
resource_id: farm_event.id,
|
||||
device: device_a)
|
||||
result = Resources::Job.run(params)
|
||||
expect(result.success?).to be false
|
||||
expect(result.errors.message_list).to include(Resources::Job::NOT_FOUND)
|
||||
end
|
||||
|
||||
it "deals with edge case resource snooping" do
|
||||
device_a = FactoryBot.create(:device)
|
||||
device_b = FactoryBot.create(:device)
|
||||
farm_event = FactoryBot.create(:farm_event, device: device_b)
|
||||
FD = CreateDestroyer.run!(resource: FarmEvent)
|
||||
result = FD.run(farm_event: farm_event, device: device_a)
|
||||
errors = result.errors.message_list
|
||||
expect(errors).to include("You do not own that farm_event")
|
||||
end
|
||||
|
||||
it "deals with points" do
|
||||
device = FactoryBot.create(:device)
|
||||
Devices::Destroy
|
||||
params = [
|
||||
FactoryBot.create(:generic_pointer, device: device),
|
||||
FactoryBot.create(:plant, device: device),
|
||||
FactoryBot.create(:tool_slot, device: device)
|
||||
].map do |r|
|
||||
base.merge({resource: Point, resource_id: r.id, device: device})
|
||||
end
|
||||
.map do |params|
|
||||
res = params[:resource]
|
||||
count = res.where(discarded_at: nil).count
|
||||
Resources::Job.run!(params)
|
||||
expect(res.where(discarded_at: nil).count).to eq(count - 1)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,89 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe Resources::PreProcessor do
|
||||
DeliveryInfoShim = Struct.new(:routing_key)
|
||||
CHANNEL_TPL =
|
||||
"bot.device_%{device_id}.resources_v0.%{action}.%{klass}.%{id}.%{uuid}"
|
||||
|
||||
let(:pb) { FactoryBot.create(:pin_binding) }
|
||||
|
||||
let(:props) do
|
||||
{ device_id: pb.device.id,
|
||||
action: "destroy",
|
||||
klass: pb.class,
|
||||
id: pb.id,
|
||||
uuid: SecureRandom.uuid }
|
||||
end
|
||||
|
||||
let(:preprocessed) do
|
||||
body = {}.to_json
|
||||
chan = CHANNEL_TPL % props
|
||||
Resources::PreProcessor.from_amqp(DeliveryInfoShim.new(chan), body)
|
||||
end
|
||||
|
||||
it "converts string types to real types" do
|
||||
expect(preprocessed[:action]).to eq("destroy")
|
||||
expect(preprocessed[:device]).to eq(pb.device)
|
||||
expect(preprocessed[:body]).to eq({})
|
||||
expect(preprocessed[:resource]).to eq(PinBinding)
|
||||
expect(preprocessed[:resource_id]).to eq(pb.id)
|
||||
expect(preprocessed[:uuid]).to eq(props[:uuid])
|
||||
end
|
||||
|
||||
it "handles bad JSON" do
|
||||
body = "}{"
|
||||
chan = CHANNEL_TPL % props
|
||||
expect do
|
||||
Resources::PreProcessor.from_amqp(DeliveryInfoShim.new(chan), body)
|
||||
end.to raise_error(Mutations::ValidationException, "body must be a JSON object")
|
||||
end
|
||||
|
||||
describe Resources::Service do
|
||||
it "handles failure" do
|
||||
body = "[]"
|
||||
chan = CHANNEL_TPL % props
|
||||
result = Resources::Service.process(DeliveryInfoShim.new(chan), body)
|
||||
err = result.calls[:publish].last
|
||||
expect(err).to be_kind_of(Array)
|
||||
expect(err.last).to be_kind_of(Hash)
|
||||
expect(err.last[:routing_key]).to be_kind_of(String)
|
||||
dev_id = err.last[:routing_key].split(".").second
|
||||
expect(dev_id).to eq("device_#{props[:device_id]}")
|
||||
body = JSON.parse(err.first).deep_symbolize_keys
|
||||
expect(body[:kind]).to eq("rpc_error")
|
||||
expect(body[:args]).to be_kind_of(Hash)
|
||||
expect(body[:body]).to be_kind_of(Array)
|
||||
expl = body[:body].first
|
||||
expect(expl).to be_kind_of(Hash)
|
||||
expect(expl[:kind]).to eq("explanation")
|
||||
expect(expl[:args][:message]).to eq("body must be a JSON object")
|
||||
end
|
||||
|
||||
it "processes resources" do
|
||||
body = {}.to_json
|
||||
chan = CHANNEL_TPL % props
|
||||
before = PinBinding.count
|
||||
result = Resources::Service.process(DeliveryInfoShim.new(chan), body)
|
||||
# expect(result).to eq("")
|
||||
expect(PinBinding.count).to be < before
|
||||
end
|
||||
end
|
||||
|
||||
describe Resources::Job do
|
||||
it "executes deletion" do
|
||||
y = preprocessed
|
||||
before = PinBinding.count
|
||||
x = Resources::Job.run(y)
|
||||
expect(x.success?).to be true
|
||||
expect(before).to be > PinBinding.count
|
||||
end
|
||||
|
||||
it "crashes when attempting to process unsupported classes" do
|
||||
y = preprocessed
|
||||
y[:resource] = Device
|
||||
y[:resource_id] = y[:device].id
|
||||
xpect = "PANIC: Tried to do batch op on Device"
|
||||
expect { Resources::Job.run(y) }.to raise_error(xpect)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
require "spec_helper"
|
||||
require "service_runner_base"
|
||||
|
||||
describe ServiceRunner do
|
||||
class ServiceRunnerStub
|
||||
attr_accessor :subscribe_call_count, :process_calls
|
||||
|
||||
MSG = RuntimeError.new("First attempt will fail, expect a retry")
|
||||
|
||||
def initialize
|
||||
@subscribe_call_count = 0
|
||||
@process_calls = []
|
||||
end
|
||||
|
||||
def subscribe(*)
|
||||
@subscribe_call_count = @subscribe_call_count + 1
|
||||
(@subscribe_call_count > 1) ? yield({}, {}, "blah") : (raise MSG)
|
||||
end
|
||||
|
||||
def process(*args)
|
||||
process_calls.push(args)
|
||||
end
|
||||
end
|
||||
|
||||
it "reports errors to rollbar and retries" do
|
||||
stub = ServiceRunnerStub.new
|
||||
expect(Rollbar).to receive(:error).with(ServiceRunnerStub::MSG)
|
||||
ServiceRunner.go!(stub, stub)
|
||||
expect(stub.subscribe_call_count).to eq(2)
|
||||
expect(stub.process_calls.count).to eq(1)
|
||||
expect(stub.process_calls.first[0]).to eq({})
|
||||
expect(stub.process_calls.first[1]).to be_kind_of(String)
|
||||
expect(stub.process_calls.first[1].encoding.to_s).to eq("UTF-8")
|
||||
end
|
||||
end
|
|
@ -1,5 +0,0 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe DataDumpMailer, type: :mailer do
|
||||
it 'sends a JSON file to users'
|
||||
end
|
|
@ -1,11 +1,11 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe Sequences::Delete do
|
||||
describe Sequences::Destroy do
|
||||
let(:sequence) { FakeSequence.create() }
|
||||
|
||||
it "Cant delete sequences in use by farm events" do
|
||||
fe = FactoryBot.create(:farm_event, executable: sequence)
|
||||
result = Sequences::Delete.run(device: sequence.device, sequence: sequence)
|
||||
result = Sequences::Destroy.run(device: sequence.device, sequence: sequence)
|
||||
expect(result.success?).to be false
|
||||
errors = result.errors.message
|
||||
expect(errors.keys).to include("sequence")
|
||||
|
@ -17,7 +17,7 @@ describe Sequences::Delete do
|
|||
regimen_item1 = FactoryBot.create(:regimen_item, sequence: sequence)
|
||||
regimen_item2 = FactoryBot.create(:regimen_item, sequence: sequence)
|
||||
expect(sequence.regimen_items.count).to eq(2)
|
||||
result = Sequences::Delete.run(device: sequence.device, sequence: sequence)
|
||||
result = Sequences::Destroy.run(device: sequence.device, sequence: sequence)
|
||||
expect(result.success?).to be false
|
||||
errors = result.errors.message
|
||||
expect(errors.keys).to include("sequence")
|
||||
|
@ -27,7 +27,7 @@ describe Sequences::Delete do
|
|||
end
|
||||
|
||||
it "deletes a sequence" do
|
||||
result = Sequences::Delete.run!(device: sequence.device, sequence: sequence)
|
||||
result = Sequences::Destroy.run!(device: sequence.device, sequence: sequence)
|
||||
expect(result).to eq("")
|
||||
expect( Sequence.where(id: sequence.id).count ).to eq(0)
|
||||
end
|
||||
|
@ -41,7 +41,7 @@ describe Sequences::Delete do
|
|||
args: { sequence_id: sequence.id }
|
||||
}
|
||||
])
|
||||
result = Sequences::Delete.run(device: sequence.device, sequence: sequence)
|
||||
result = Sequences::Destroy.run(device: sequence.device, sequence: sequence)
|
||||
expect(result.success?).to be(false)
|
||||
expect(result.errors.has_key?("sequence")).to be(true)
|
||||
message = result.errors["sequence"].message
|
||||
|
|
|
@ -15,6 +15,7 @@ import { ExecutableType } from "../../farm_designer/interfaces";
|
|||
import { fakeResource } from "../fake_resource";
|
||||
import { emptyToolSlot } from "../../tools/components/empty_tool_slot";
|
||||
import { FirmwareConfig } from "../../config_storage/firmware_configs";
|
||||
import { PinBindingType } from "../../devices/pin_bindings/interfaces";
|
||||
|
||||
export let resources: Everything["resources"] = buildResourceIndex();
|
||||
let idCounter = 1;
|
||||
|
@ -164,7 +165,7 @@ export function fakePinBinding(): TaggedPinBinding {
|
|||
id: idCounter++,
|
||||
pin_num: 10,
|
||||
sequence_id: 1,
|
||||
binding_type: "standard"
|
||||
binding_type: PinBindingType.standard,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -3,5 +3,6 @@ jest.mock("farmbot-toastr", () => ({
|
|||
init: jest.fn(),
|
||||
success: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn()
|
||||
error: jest.fn(),
|
||||
warning: jest.fn()
|
||||
}));
|
||||
|
|
|
@ -49,6 +49,7 @@ describe("destroy", () => {
|
|||
};
|
||||
|
||||
it("not confirmed", () => {
|
||||
window.confirm = () => false;
|
||||
expect(fakeDestroy()).rejects.toEqual("User pressed cancel");
|
||||
expectNotDestroyed();
|
||||
});
|
||||
|
|
|
@ -302,6 +302,7 @@ a {
|
|||
}
|
||||
.bindings-list {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import { HardwareSettings, FwParamExportMenu } from "../hardware_settings";
|
||||
import { HardwareSettings } from "../hardware_settings";
|
||||
import { HardwareSettingsProps } from "../../interfaces";
|
||||
import { Actions } from "../../../constants";
|
||||
import { bot } from "../../../__test_support__/fake_state/bot";
|
||||
|
@ -78,11 +78,3 @@ describe("<HardwareSettings />", () => {
|
|||
expect(wrapper.find("Popover").length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<FwParamExportMenu />", () => {
|
||||
it("lists all params", () => {
|
||||
const config = fakeFirmwareConfig().body;
|
||||
const wrapper = mount(<FwParamExportMenu firmwareConfig={config} />);
|
||||
expect(wrapper.text()).toContain("movement_max_spd_");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,146 +0,0 @@
|
|||
const mockDevice = {
|
||||
registerGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
unregisterGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
};
|
||||
jest.mock("../../../device", () => ({
|
||||
getDevice: () => (mockDevice)
|
||||
}));
|
||||
|
||||
jest.mock("../../../api/crud", () => ({
|
||||
destroy: jest.fn(),
|
||||
initSave: jest.fn()
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { PinBindings, PinBindingsProps } from "../pin_bindings";
|
||||
import { mount } from "enzyme";
|
||||
import { bot } from "../../../__test_support__/fake_state/bot";
|
||||
import {
|
||||
buildResourceIndex
|
||||
} from "../../../__test_support__/resource_index_builder";
|
||||
import { TaggedSequence } from "../../../resources/tagged_resources";
|
||||
import {
|
||||
fakeSequence, fakePinBinding
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import { destroy, initSave } from "../../../api/crud";
|
||||
|
||||
describe("<PinBindings/>", () => {
|
||||
beforeEach(function () {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function fakeProps(): PinBindingsProps {
|
||||
const fakeResources: TaggedSequence[] = [fakeSequence(), fakeSequence()];
|
||||
fakeResources[0].body.id = 1;
|
||||
fakeResources[0].body.name = "Sequence 1";
|
||||
fakeResources[1].body.id = 2;
|
||||
fakeResources[1].body.name = "Sequence 2";
|
||||
const resources = buildResourceIndex(fakeResources).index;
|
||||
|
||||
bot.hardware.gpio_registry = {
|
||||
10: "1",
|
||||
11: "2"
|
||||
};
|
||||
return {
|
||||
dispatch: jest.fn(),
|
||||
bot: bot,
|
||||
resources: resources,
|
||||
botToMqttStatus: "up",
|
||||
shouldDisplay: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<PinBindings {...fakeProps()} />);
|
||||
["pin bindings", "pin number", "none", "bind"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
["pi gpio 10", "sequence 1", "pi gpio 11", "sequence 2"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
expect(wrapper.find("input").length).toBe(1);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("unregisters pin: bot", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn(x => x(jest.fn()));
|
||||
const wrapper = mount(<PinBindings {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
buttons.first().simulate("click");
|
||||
expect(mockDevice.unregisterGpio).toHaveBeenCalledWith({
|
||||
pin_number: 10
|
||||
});
|
||||
});
|
||||
|
||||
it("unregisters pin: api", () => {
|
||||
const p = fakeProps();
|
||||
const s = fakeSequence();
|
||||
s.body.id = 1;
|
||||
p.resources = buildResourceIndex([fakePinBinding(), s]).index;
|
||||
p.shouldDisplay = () => true;
|
||||
const wrapper = mount(<PinBindings {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
buttons.first().simulate("click");
|
||||
expect(mockDevice.unregisterGpio).not.toHaveBeenCalled();
|
||||
expect(destroy).toHaveBeenCalledWith(expect.stringContaining("PinBinding"));
|
||||
});
|
||||
|
||||
it("registers pin: bot", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn(x => x(jest.fn()));
|
||||
const wrapper = mount(<PinBindings {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({ pinNumberInput: 1, sequenceIdInput: 2 });
|
||||
buttons.last().simulate("click");
|
||||
expect(mockDevice.registerGpio).toHaveBeenCalledWith({
|
||||
pin_number: 1, sequence_id: 2
|
||||
});
|
||||
});
|
||||
|
||||
it("registers pin: api", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn();
|
||||
p.shouldDisplay = () => true;
|
||||
const wrapper = mount(<PinBindings {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({ pinNumberInput: 1, sequenceIdInput: 2 });
|
||||
buttons.last().simulate("click");
|
||||
expect(mockDevice.registerGpio).not.toHaveBeenCalled();
|
||||
const expectedResult = expect.objectContaining({
|
||||
kind: "PinBinding",
|
||||
body: {
|
||||
pin_num: 1,
|
||||
sequence_id: 2,
|
||||
binding_type: "standard"
|
||||
}
|
||||
});
|
||||
expect(initSave).toHaveBeenCalledWith(expectedResult);
|
||||
});
|
||||
|
||||
it("sets sequence id", () => {
|
||||
const p = fakeProps();
|
||||
const s = p.resources.references[p.resources.byKind.Sequence[0]];
|
||||
const id = s && s.body.id;
|
||||
const wrapper = mount<PinBindings>(<PinBindings {...p} />);
|
||||
expect(wrapper.instance().state.sequenceIdInput).toEqual(undefined);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.changeSelection({ label: "label", value: id });
|
||||
expect(wrapper.instance().state.sequenceIdInput).toEqual(id);
|
||||
});
|
||||
|
||||
it("sets pin", () => {
|
||||
const wrapper = mount<PinBindings>(<PinBindings {...fakeProps()} />);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.setSelectedPin(10);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
instance.setSelectedPin(99);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
instance.setSelectedPin(5);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(5);
|
||||
});
|
||||
});
|
|
@ -15,21 +15,7 @@ import {
|
|||
} from "./hardware_settings/homing_and_calibration";
|
||||
import { SpecialStatus } from "../../resources/tagged_resources";
|
||||
import { Popover, Position } from "@blueprintjs/core";
|
||||
import { FirmwareConfig } from "../../config_storage/firmware_configs";
|
||||
import { pickBy } from "lodash";
|
||||
|
||||
export const FwParamExportMenu = (props: { firmwareConfig: FirmwareConfig }) => {
|
||||
const filteredConfig = pickBy(props.firmwareConfig, (_, key) =>
|
||||
!["id", "device_id", "api_migrated", "created_at", "updated_at",
|
||||
"param_test", "param_version"]
|
||||
.includes(key));
|
||||
return <div className={"firmware-setting-export-menu"}>
|
||||
<ul>
|
||||
{Object.entries(filteredConfig).map(([param, value]) =>
|
||||
<li key={param}>{param}: {value}</li>)}
|
||||
</ul>
|
||||
</div>;
|
||||
};
|
||||
import { FwParamExportMenu } from "./hardware_settings/export_menu";
|
||||
|
||||
export class HardwareSettings extends
|
||||
React.Component<HardwareSettingsProps, {}> {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import {
|
||||
FwParamExportMenu, condenseFwConfig, uncondenseFwConfig
|
||||
} from "../export_menu";
|
||||
import {
|
||||
fakeFirmwareConfig
|
||||
} from "../../../../__test_support__/fake_state/resources";
|
||||
|
||||
describe("<FwParamExportMenu />", () => {
|
||||
it("lists all params", () => {
|
||||
const config = fakeFirmwareConfig().body;
|
||||
const wrapper = mount(<FwParamExportMenu firmwareConfig={config} />);
|
||||
expect(wrapper.text()).toContain(
|
||||
"\"encoder_enabled\": {\"x\": 0, \"y\": 0, \"z\": 0 },");
|
||||
expect(wrapper.text()).toContain(
|
||||
"\"pin_guard_1\": {\"active_state\": 1, " +
|
||||
"\"pin_nr\": 0, \"time_out\": 60 },");
|
||||
expect(wrapper.text()).toContain(
|
||||
"\"param_mov_nr_retry\": {\"\": 3 },");
|
||||
});
|
||||
});
|
||||
|
||||
describe("condenseFwConfig()", () => {
|
||||
it("condenses config", () => {
|
||||
const config = fakeFirmwareConfig().body;
|
||||
expect(condenseFwConfig(config)).toEqual(expect.objectContaining({
|
||||
encoder_enabled: { x: 0, y: 0, z: 0 }
|
||||
}));
|
||||
expect(condenseFwConfig(config)).toEqual(expect.objectContaining({
|
||||
pin_guard_1: { active_state: 1, pin_nr: 0, time_out: 60 }
|
||||
}));
|
||||
expect(condenseFwConfig(config)).toEqual(expect.objectContaining({
|
||||
param_mov_nr_retry: { "": 3 }
|
||||
}));
|
||||
});
|
||||
|
||||
it("is reversible", () => {
|
||||
const config = fakeFirmwareConfig().body;
|
||||
const result = uncondenseFwConfig(condenseFwConfig(config));
|
||||
expect(result).toEqual(config);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,96 @@
|
|||
import * as React from "react";
|
||||
import { FirmwareConfig } from "../../../config_storage/firmware_configs";
|
||||
import { pickBy } from "lodash";
|
||||
|
||||
/** i.e., { encoder_enabled: { x: 1, y: 1, z: 1 } } */
|
||||
type CondensedFwConfig = {
|
||||
[key: string]: {
|
||||
[subkey: string]: boolean | number | string | undefined
|
||||
}
|
||||
};
|
||||
|
||||
const isAxisKey = (key: string) =>
|
||||
["_x", "_y", "_z"].includes(key.slice(-2));
|
||||
const isPinGuardKey = (key: string) =>
|
||||
["_active_state", "_pin_nr", "_time_out"].includes(key.slice(11));
|
||||
|
||||
const getSubKeyName = (key: string) => {
|
||||
if (isAxisKey(key)) {
|
||||
return key.slice(-1);
|
||||
} else if (isPinGuardKey(key)) {
|
||||
return key.slice(12);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const FwParamExportMenu =
|
||||
({ firmwareConfig }: { firmwareConfig: FirmwareConfig }) => {
|
||||
/** Filter out unnecessary parameters. */
|
||||
const filteredConfig = pickBy(firmwareConfig, (_, key) =>
|
||||
!["id", "device_id", "api_migrated", "created_at", "updated_at",
|
||||
"param_test", "param_version"].includes(key));
|
||||
|
||||
const condensedFwConfig = condenseFwConfig(filteredConfig);
|
||||
|
||||
/** Total number of condensed firmware config keys. */
|
||||
const entryCount = Object.keys(condensedFwConfig).length;
|
||||
|
||||
/** Display in JSON using an unordered HTML list. */
|
||||
return <div className={"firmware-setting-export-menu"}>
|
||||
<ul>
|
||||
<li key={"header"}>{"{"}</li>
|
||||
{Object.entries(condensedFwConfig).map(([key, obj], keyNumber) => {
|
||||
const entryComma = (keyNumber + 1) === entryCount ? "" : ",";
|
||||
const subEntryCount = Object.keys(obj).length;
|
||||
return <li key={key}>
|
||||
{`"${key}": {`}
|
||||
{Object.entries(obj).map(([subKey, value], subKeyNo) => {
|
||||
const subentryComma = (subKeyNo + 1) === subEntryCount ? "" : ",";
|
||||
return `"${subKey}": ${value}${subentryComma} `;
|
||||
})}
|
||||
{`}${entryComma}`}
|
||||
</li>;
|
||||
})}
|
||||
<li key={"footer"}>{"}"}</li>
|
||||
</ul>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const condenseFwConfig =
|
||||
(fwConfig: Partial<FirmwareConfig>): CondensedFwConfig => {
|
||||
/** Set of parameter keys without suffixes such as `_<x|y|z>`. */
|
||||
const reducedParamKeys = new Set(Object.keys(fwConfig)
|
||||
.map(key => isAxisKey(key) ? key.slice(0, -2) : key)
|
||||
.map(key => isPinGuardKey(key) ? key.slice(0, 11) : key));
|
||||
|
||||
const condensedFwConfig: CondensedFwConfig = {};
|
||||
|
||||
/** Add keys. */
|
||||
Array.from(reducedParamKeys).map(key => condensedFwConfig[key] = {});
|
||||
|
||||
/** Add subkeys and values. */
|
||||
Object.entries(fwConfig).map(([fwConfigKey, value]) => {
|
||||
Array.from(reducedParamKeys).map(key => {
|
||||
if (fwConfigKey.startsWith(key)) {
|
||||
condensedFwConfig[key][getSubKeyName(fwConfigKey)] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return condensedFwConfig;
|
||||
};
|
||||
|
||||
export const uncondenseFwConfig =
|
||||
(condensed: CondensedFwConfig): Partial<FirmwareConfig> => {
|
||||
const uncondensedFwConfig: {
|
||||
[key: string]: boolean | number | string | undefined
|
||||
} = {};
|
||||
Object.entries(condensed).map(([key, obj]) =>
|
||||
Object.entries(obj).map(([subKey, value]) => {
|
||||
const fwConfigKey = subKey != "" ? `${key}_${subKey}` : key;
|
||||
uncondensedFwConfig[fwConfigKey] = value;
|
||||
}
|
||||
));
|
||||
return uncondensedFwConfig;
|
||||
};
|
|
@ -1,269 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import * as _ from "lodash";
|
||||
import {
|
||||
Widget, WidgetBody, WidgetHeader,
|
||||
Row, Col,
|
||||
BlurableInput,
|
||||
DropDownItem
|
||||
} from "../../ui/index";
|
||||
import { ToolTips } from "../../constants";
|
||||
import { BotState, ShouldDisplay, Feature } from "../interfaces";
|
||||
import { registerGpioPin, unregisterGpioPin } from "../actions";
|
||||
import { findSequenceById, selectAllPinBindings } from "../../resources/selectors";
|
||||
import { ResourceIndex } from "../../resources/interfaces";
|
||||
import { MustBeOnline } from "../must_be_online";
|
||||
import { Popover, Position } from "@blueprintjs/core";
|
||||
import { RpiGpioDiagram, gpio } from "./rpi_gpio_diagram";
|
||||
import { error } from "farmbot-toastr";
|
||||
import { NetworkState } from "../../connectivity/interfaces";
|
||||
import { SequenceSelectBox } from "../../sequences/sequence_select_box";
|
||||
import { initSave, destroy } from "../../api/crud";
|
||||
import { TaggedPinBinding, SpecialStatus } from "../../resources/tagged_resources";
|
||||
|
||||
/**
|
||||
* PROBLEM SCENARIO: New FarmBot boots up. It does not have any sequences yet,
|
||||
* because it's never talked with the API. You _must_ be able to E-stop the
|
||||
* device via button press.
|
||||
*
|
||||
* SOLUTION: Bake some sane defaults (heretofore "builtin sequences" and
|
||||
* "builtin bindings") into the FBOS image. Make the
|
||||
*
|
||||
* NEW PROBLEM: We need a way to map FBOS friendly magic numbers to human
|
||||
* friendly labels on the UI layer.
|
||||
*
|
||||
* SOLUTION: Hard code a magic number mapping in to the FE.
|
||||
*
|
||||
* PRECAUTIONS:
|
||||
* + Numbers can never change (will break old FBOS versions)
|
||||
* + If we ever need to share this mapping, keep it in the API as a constant.
|
||||
* + Numbers will cause runtime errors if sent to the API. Best to keep these
|
||||
* numbers local to FE/FBOS.
|
||||
*/
|
||||
export const magicNumbers = {
|
||||
sequences: {
|
||||
emergency_lock: -1,
|
||||
emergency_unlock: -2,
|
||||
sync: -3,
|
||||
reboot: -4,
|
||||
power_off: -5,
|
||||
},
|
||||
pin_bindings: {
|
||||
emergency_lock: -1,
|
||||
emergency_unlock: -2
|
||||
}
|
||||
};
|
||||
|
||||
export interface PinBindingsProps {
|
||||
bot: BotState;
|
||||
dispatch: Function;
|
||||
botToMqttStatus: NetworkState;
|
||||
resources: ResourceIndex;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
}
|
||||
|
||||
export interface PinBindingsState {
|
||||
isEditing: boolean;
|
||||
pinNumberInput: number | undefined;
|
||||
sequenceIdInput: number | undefined;
|
||||
}
|
||||
|
||||
enum ColumnWidth {
|
||||
pin = 4,
|
||||
sequence = 7,
|
||||
button = 1
|
||||
}
|
||||
|
||||
export class PinBindings
|
||||
extends React.Component<PinBindingsProps, PinBindingsState> {
|
||||
constructor(props: PinBindingsProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isEditing: false,
|
||||
pinNumberInput: undefined,
|
||||
sequenceIdInput: undefined
|
||||
};
|
||||
}
|
||||
|
||||
get pinBindings(): {
|
||||
pin_number: number, sequence_id: number, uuid?: string
|
||||
}[] {
|
||||
if (this.props.shouldDisplay(Feature.api_pin_bindings)) {
|
||||
return selectAllPinBindings(this.props.resources)
|
||||
.map(x => {
|
||||
const { body } = x;
|
||||
const sequence_id = // TODO: Handle special bindings.
|
||||
body.binding_type == "standard" ? body.sequence_id : 0;
|
||||
return {
|
||||
pin_number: x.body.pin_num,
|
||||
sequence_id,
|
||||
uuid: x.uuid
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const { gpio_registry } = this.props.bot.hardware;
|
||||
return Object.entries(gpio_registry || {})
|
||||
.map(([pin_number, sequence_id]) => {
|
||||
return {
|
||||
pin_number: parseInt(pin_number),
|
||||
sequence_id: parseInt(sequence_id || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changeSelection = (input: DropDownItem) => {
|
||||
this.setState({ sequenceIdInput: parseInt(input.value as string) });
|
||||
}
|
||||
|
||||
setSelectedPin = (pin: number | undefined) => {
|
||||
if (!_.includes(this.boundPins, pin)) {
|
||||
if (_.includes(_.flattenDeep(gpio), pin)) {
|
||||
this.setState({ pinNumberInput: pin });
|
||||
} else {
|
||||
error("Invalid Raspberry Pi GPIO pin number.");
|
||||
}
|
||||
} else {
|
||||
error("Raspberry Pi GPIO pin already bound.");
|
||||
}
|
||||
}
|
||||
|
||||
taggedPinBinding =
|
||||
(pin_num: number, sequence_id: number): TaggedPinBinding => {
|
||||
return {
|
||||
uuid: "WILL_BE_CHANGED_BY_REDUCER",
|
||||
specialStatus: SpecialStatus.SAVED,
|
||||
kind: "PinBinding",
|
||||
body: { pin_num, sequence_id, binding_type: "standard" }
|
||||
};
|
||||
}
|
||||
|
||||
bindPin = () => {
|
||||
const { pinNumberInput, sequenceIdInput } = this.state;
|
||||
if (pinNumberInput && sequenceIdInput) {
|
||||
if (this.props.shouldDisplay(Feature.api_pin_bindings)) {
|
||||
this.props.dispatch(initSave(
|
||||
this.taggedPinBinding(pinNumberInput, sequenceIdInput)));
|
||||
} else {
|
||||
this.props.dispatch(registerGpioPin({
|
||||
pin_number: pinNumberInput,
|
||||
sequence_id: sequenceIdInput
|
||||
}));
|
||||
}
|
||||
this.setState({
|
||||
pinNumberInput: undefined,
|
||||
sequenceIdInput: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deleteBinding = (pin: number, uuid?: string) => {
|
||||
if (this.props.shouldDisplay(Feature.api_pin_bindings)) {
|
||||
this.props.dispatch(destroy(uuid || ""));
|
||||
} else {
|
||||
this.props.dispatch(unregisterGpioPin(pin));
|
||||
}
|
||||
}
|
||||
|
||||
get boundPins(): number[] | undefined {
|
||||
return this.pinBindings.map(x => x.pin_number);
|
||||
}
|
||||
|
||||
currentBindingsList = () => {
|
||||
const { resources } = this.props;
|
||||
return <div className={"bindings-list"}>
|
||||
{this.pinBindings
|
||||
.map(x => {
|
||||
const { pin_number, sequence_id } = x;
|
||||
return <Row key={`pin_${pin_number}_binding`}>
|
||||
<Col xs={ColumnWidth.pin}>
|
||||
{`Pi GPIO ${pin_number}`}
|
||||
</Col>
|
||||
<Col xs={ColumnWidth.sequence}>
|
||||
{sequence_id ? findSequenceById(
|
||||
resources, sequence_id).body.name : ""}
|
||||
</Col>
|
||||
<Col xs={ColumnWidth.button}>
|
||||
<button
|
||||
className="fb-button red"
|
||||
onClick={() => this.deleteBinding(pin_number, x.uuid)}>
|
||||
<i className="fa fa-minus" />
|
||||
</button>
|
||||
</Col>
|
||||
</Row>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
pinBindingInputGroup = () => {
|
||||
const { pinNumberInput, sequenceIdInput } = this.state;
|
||||
return <Row>
|
||||
<Col xs={ColumnWidth.pin}>
|
||||
<Row>
|
||||
<Col xs={1}>
|
||||
<Popover position={Position.TOP}>
|
||||
<i className="fa fa-th-large" />
|
||||
<RpiGpioDiagram
|
||||
boundPins={this.boundPins}
|
||||
setSelectedPin={this.setSelectedPin}
|
||||
selectedPin={this.state.pinNumberInput} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={9}>
|
||||
<BlurableInput
|
||||
onCommit={(e) =>
|
||||
this.setSelectedPin(parseInt(e.currentTarget.value))}
|
||||
name="pin_number"
|
||||
value={_.isNumber(pinNumberInput) ? pinNumberInput : ""}
|
||||
type="number" />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={ColumnWidth.sequence}>
|
||||
<SequenceSelectBox
|
||||
key={sequenceIdInput}
|
||||
onChange={this.changeSelection}
|
||||
resources={this.props.resources}
|
||||
sequenceId={sequenceIdInput} />
|
||||
</Col>
|
||||
<Col xs={ColumnWidth.button}>
|
||||
<button
|
||||
className="fb-button green"
|
||||
type="button"
|
||||
onClick={() => { this.bindPin(); }} >
|
||||
{t("BIND")}
|
||||
</button>
|
||||
</Col>
|
||||
</Row>;
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Widget className="pin-bindings-widget">
|
||||
<WidgetHeader
|
||||
title={t("Pin Bindings")}
|
||||
helpText={ToolTips.PIN_BINDINGS} />
|
||||
<WidgetBody>
|
||||
<MustBeOnline
|
||||
syncStatus={this.props.bot.hardware.informational_settings.sync_status}
|
||||
networkState={this.props.botToMqttStatus}
|
||||
lockOpen={this.props.shouldDisplay(Feature.api_pin_bindings)
|
||||
|| process.env.NODE_ENV !== "production"}>
|
||||
<Row>
|
||||
<Col xs={ColumnWidth.pin}>
|
||||
<label>
|
||||
{t("Pin Number")}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={ColumnWidth.sequence}>
|
||||
<label>
|
||||
{t("Sequence")}
|
||||
</label>
|
||||
</Col>
|
||||
</Row>
|
||||
<this.currentBindingsList />
|
||||
<this.pinBindingInputGroup />
|
||||
</MustBeOnline>
|
||||
</WidgetBody>
|
||||
</Widget>;
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { Diagnosis, DiagnosisName } from "./connectivity/diagnosis";
|
||||
import { StatusRowProps } from "./connectivity/connectivity_row";
|
||||
import { resetConnectionInfo } from "./actions";
|
||||
import { PinBindings } from "./components/pin_bindings";
|
||||
import { PinBindings } from "./pin_bindings/pin_bindings";
|
||||
import { selectAllDiagnosticDumps } from "../resources/selectors";
|
||||
|
||||
@connect(mapStateToProps)
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import { sortByNameAndPin, ButtonPin } from "../list_and_label_support";
|
||||
|
||||
describe("sortByNameAndPin()", () => {
|
||||
|
||||
enum Order {
|
||||
firstSmaller = -1,
|
||||
secondSmaller = 1,
|
||||
equal = 0,
|
||||
}
|
||||
|
||||
const btn1Pin = ButtonPin.estop;
|
||||
const btn2Pin = ButtonPin.unlock;
|
||||
|
||||
const sortTest = (first: number, second: number, order: Order) =>
|
||||
expect(sortByNameAndPin(first, second)).toEqual(order);
|
||||
|
||||
it("sorts", () => {
|
||||
sortTest(btn1Pin, 10, Order.firstSmaller); // Button 1 < GPIO 10
|
||||
sortTest(2, 10, Order.firstSmaller); // GPIO 2 < GPIO 10
|
||||
sortTest(btn1Pin, btn2Pin, Order.firstSmaller); // Button 1 < Button 2
|
||||
sortTest(btn2Pin, btn1Pin, Order.secondSmaller); // Button 2 > Button 1
|
||||
sortTest(1, 1, Order.equal); // GPIO 1 == GPIO 1
|
||||
});
|
||||
});
|
|
@ -0,0 +1,242 @@
|
|||
const mockDevice = {
|
||||
registerGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
unregisterGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
};
|
||||
jest.mock("../../../device", () => ({
|
||||
getDevice: () => (mockDevice)
|
||||
}));
|
||||
|
||||
jest.mock("../../../api/crud", () => ({
|
||||
initSave: jest.fn()
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import {
|
||||
buildResourceIndex
|
||||
} from "../../../__test_support__/resource_index_builder";
|
||||
import { TaggedSequence } from "../../../resources/tagged_resources";
|
||||
import {
|
||||
fakeSequence
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import { initSave } from "../../../api/crud";
|
||||
import {
|
||||
PinBindingInputGroupProps, PinBindingType, PinBindingSpecialAction
|
||||
} from "../interfaces";
|
||||
import {
|
||||
PinBindingInputGroup, PinNumberInputGroup, BindingTypeDropDown,
|
||||
ActionTargetDropDown, SequenceTargetDropDown
|
||||
} from "../pin_binding_input_group";
|
||||
import { error, warning } from "farmbot-toastr";
|
||||
import {
|
||||
fakeResourceIndex
|
||||
} from "../../../sequences/step_tiles/tile_move_absolute/test_helpers";
|
||||
|
||||
describe("<PinBindingInputGroup/>", () => {
|
||||
beforeEach(function () {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function fakeProps(): PinBindingInputGroupProps {
|
||||
const fakeResources: TaggedSequence[] = [fakeSequence(), fakeSequence()];
|
||||
fakeResources[0].body.id = 1;
|
||||
fakeResources[0].body.name = "Sequence 1";
|
||||
fakeResources[1].body.id = 2;
|
||||
fakeResources[1].body.name = "Sequence 2";
|
||||
const resources = buildResourceIndex(fakeResources).index;
|
||||
return {
|
||||
pinBindings: [
|
||||
{ pin_number: 10, sequence_id: 1 },
|
||||
{ pin_number: 11, sequence_id: 2 },
|
||||
],
|
||||
dispatch: jest.fn(),
|
||||
resources: resources,
|
||||
shouldDisplay: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<PinBindingInputGroup {...fakeProps()} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.length).toBe(4);
|
||||
});
|
||||
|
||||
it("no pin selected", () => {
|
||||
const wrapper = mount(<PinBindingInputGroup {...fakeProps()} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
buttons.last().simulate("click");
|
||||
expect(error).toHaveBeenCalledWith("Pin number cannot be blank.");
|
||||
});
|
||||
|
||||
it("no target selected", () => {
|
||||
const wrapper = mount(<PinBindingInputGroup {...fakeProps()} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({ pinNumberInput: 7 });
|
||||
buttons.last().simulate("click");
|
||||
expect(error).toHaveBeenCalledWith("Please select a sequence or action.");
|
||||
});
|
||||
|
||||
it("registers pin: bot", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn(x => x(jest.fn()));
|
||||
const wrapper = mount(<PinBindingInputGroup {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({ pinNumberInput: 1, sequenceIdInput: 2 });
|
||||
buttons.last().simulate("click");
|
||||
expect(mockDevice.registerGpio).toHaveBeenCalledWith({
|
||||
pin_number: 1, sequence_id: 2
|
||||
});
|
||||
});
|
||||
|
||||
it("registers pin: api", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn();
|
||||
p.shouldDisplay = () => true;
|
||||
const wrapper = mount(<PinBindingInputGroup {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({ pinNumberInput: 1, sequenceIdInput: 2 });
|
||||
buttons.last().simulate("click");
|
||||
expect(mockDevice.registerGpio).not.toHaveBeenCalled();
|
||||
const expectedResult = expect.objectContaining({
|
||||
kind: "PinBinding",
|
||||
body: {
|
||||
pin_num: 1,
|
||||
sequence_id: 2,
|
||||
binding_type: PinBindingType.standard
|
||||
}
|
||||
});
|
||||
expect(initSave).toHaveBeenCalledWith(expectedResult);
|
||||
});
|
||||
|
||||
it("registers pin: api (special action)", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn();
|
||||
p.shouldDisplay = () => true;
|
||||
const wrapper = mount(<PinBindingInputGroup {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.last().text()).toEqual("BIND");
|
||||
wrapper.setState({
|
||||
pinNumberInput: 2,
|
||||
bindingType: PinBindingType.special,
|
||||
sequenceIdInput: undefined,
|
||||
specialActionInput: PinBindingSpecialAction.emergency_lock
|
||||
});
|
||||
buttons.last().simulate("click");
|
||||
expect(mockDevice.registerGpio).not.toHaveBeenCalled();
|
||||
const expectedResult = expect.objectContaining({
|
||||
kind: "PinBinding",
|
||||
body: {
|
||||
pin_num: 2,
|
||||
binding_type: PinBindingType.special,
|
||||
special_action: PinBindingSpecialAction.emergency_lock
|
||||
}
|
||||
});
|
||||
expect(initSave).toHaveBeenCalledWith(expectedResult);
|
||||
});
|
||||
|
||||
it("sets sequence id", () => {
|
||||
const p = fakeProps();
|
||||
const s = p.resources.references[p.resources.byKind.Sequence[0]];
|
||||
const id = s && s.body.id;
|
||||
const wrapper = mount<PinBindingInputGroup>(<PinBindingInputGroup {...p} />);
|
||||
expect(wrapper.instance().state.sequenceIdInput).toEqual(undefined);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.setSequenceIdInput({ label: "label", value: id });
|
||||
expect(wrapper.instance().state.sequenceIdInput).toEqual(id);
|
||||
});
|
||||
|
||||
it("sets pin", () => {
|
||||
const wrapper = mount<PinBindingInputGroup>(<PinBindingInputGroup {...fakeProps()} />);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
// tslint:disable-next-line:no-any
|
||||
const instance = wrapper.instance() as any;
|
||||
instance.setSelectedPin(10); // pin already bound
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
instance.setSelectedPin(99); // invalid pin
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
instance.setSelectedPin(5); // available pin
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(5);
|
||||
instance.setSelectedPin(1); // reserved pin
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(1);
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
"Reserved Raspberry Pi pin may not work as expected.");
|
||||
});
|
||||
|
||||
it("changes pin number", () => {
|
||||
const wrapper = shallow<PinBindingInputGroup>(<PinBindingInputGroup {...fakeProps()} />);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(undefined);
|
||||
wrapper.instance().setSelectedPin(7);
|
||||
expect(wrapper.instance().state.pinNumberInput).toEqual(7);
|
||||
});
|
||||
|
||||
it("changes binding type", () => {
|
||||
const wrapper = shallow<PinBindingInputGroup>(<PinBindingInputGroup {...fakeProps()} />);
|
||||
expect(wrapper.instance().state.bindingType).toEqual(PinBindingType.standard);
|
||||
wrapper.instance().setBindingType({ label: "", value: PinBindingType.special });
|
||||
expect(wrapper.instance().state.bindingType).toEqual(PinBindingType.special);
|
||||
});
|
||||
|
||||
it("changes special action", () => {
|
||||
const wrapper = shallow<PinBindingInputGroup>(<PinBindingInputGroup {...fakeProps()} />);
|
||||
wrapper.setState({ bindingType: PinBindingType.special });
|
||||
expect(wrapper.instance().state.specialActionInput).toEqual(undefined);
|
||||
wrapper.instance().setSpecialAction({ label: "", value: PinBindingSpecialAction.sync });
|
||||
expect(wrapper.instance().state.specialActionInput)
|
||||
.toEqual(PinBindingSpecialAction.sync);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<PinNumberInputGroup />", () => {
|
||||
it("sets pin", () => {
|
||||
const setSelectedPin = jest.fn();
|
||||
const wrapper = shallow(<PinNumberInputGroup
|
||||
pinNumberInput={undefined}
|
||||
boundPins={[]}
|
||||
setSelectedPin={setSelectedPin} />);
|
||||
wrapper.find("FBSelect").simulate("change", { label: "", value: 7 });
|
||||
expect(setSelectedPin).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BindingTypeDropDown />", () => {
|
||||
it("sets binding type", () => {
|
||||
const setBindingType = jest.fn();
|
||||
const wrapper = shallow(<BindingTypeDropDown
|
||||
bindingType={PinBindingType.standard}
|
||||
shouldDisplay={() => true}
|
||||
setBindingType={setBindingType} />);
|
||||
const ddi = { label: "", value: PinBindingType.special };
|
||||
wrapper.find("FBSelect").simulate("change", ddi);
|
||||
expect(setBindingType).toHaveBeenCalledWith(ddi);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ActionTargetDropDown />", () => {
|
||||
it("sets action", () => {
|
||||
const setSpecialAction = jest.fn();
|
||||
const wrapper = shallow(<ActionTargetDropDown
|
||||
specialActionInput={undefined}
|
||||
setSpecialAction={setSpecialAction} />);
|
||||
const ddi = { label: "", value: PinBindingSpecialAction.sync };
|
||||
wrapper.find("FBSelect").simulate("change", ddi);
|
||||
expect(setSpecialAction).toHaveBeenCalledWith(ddi);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SequenceTargetDropDown />", () => {
|
||||
it("sets action", () => {
|
||||
const setSequenceIdInput = jest.fn();
|
||||
const wrapper = shallow(<SequenceTargetDropDown
|
||||
sequenceIdInput={undefined}
|
||||
resources={fakeResourceIndex()}
|
||||
setSequenceIdInput={setSequenceIdInput} />);
|
||||
const ddi = { label: "", value: 1 };
|
||||
wrapper.find("SequenceSelectBox").simulate("change", ddi);
|
||||
expect(setSequenceIdInput).toHaveBeenCalledWith(ddi);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,98 @@
|
|||
const mockDevice = {
|
||||
registerGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
unregisterGpio: jest.fn(() => { return Promise.resolve(); }),
|
||||
};
|
||||
jest.mock("../../../device", () => ({
|
||||
getDevice: () => (mockDevice)
|
||||
}));
|
||||
|
||||
jest.mock("../../../api/crud", () => ({
|
||||
destroy: jest.fn()
|
||||
}));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount } from "enzyme";
|
||||
import {
|
||||
buildResourceIndex
|
||||
} from "../../../__test_support__/resource_index_builder";
|
||||
import { TaggedSequence } from "../../../resources/tagged_resources";
|
||||
import {
|
||||
fakeSequence, fakePinBinding
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import { destroy } from "../../../api/crud";
|
||||
import { PinBindingsList } from "../pin_bindings_list";
|
||||
import { PinBindingsListProps } from "../interfaces";
|
||||
import { error } from "farmbot-toastr";
|
||||
import { sysBtnBindingData } from "../list_and_label_support";
|
||||
|
||||
describe("<PinBindingsList/>", () => {
|
||||
beforeEach(function () {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function fakeProps(): PinBindingsListProps {
|
||||
const fakeResources: TaggedSequence[] = [fakeSequence(), fakeSequence()];
|
||||
fakeResources[0].body.id = 1;
|
||||
fakeResources[0].body.name = "Sequence 1";
|
||||
fakeResources[1].body.id = 2;
|
||||
fakeResources[1].body.name = "Sequence 2";
|
||||
const resources = buildResourceIndex(fakeResources).index;
|
||||
|
||||
return {
|
||||
pinBindings: [
|
||||
{ pin_number: 10, sequence_id: 1 },
|
||||
{ pin_number: 11, sequence_id: 2 },
|
||||
],
|
||||
dispatch: jest.fn(),
|
||||
resources: resources,
|
||||
shouldDisplay: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders", () => {
|
||||
const wrapper = mount(<PinBindingsList {...fakeProps()} />);
|
||||
["pi gpio 10", "sequence 1", "pi gpio 11", "sequence 2"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("unregisters pin: bot", () => {
|
||||
const p = fakeProps();
|
||||
p.dispatch = jest.fn(x => x(jest.fn()));
|
||||
const wrapper = mount(<PinBindingsList {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
buttons.first().simulate("click");
|
||||
expect(mockDevice.unregisterGpio).toHaveBeenCalledWith({
|
||||
pin_number: 10
|
||||
});
|
||||
});
|
||||
|
||||
it("unregisters pin: api", () => {
|
||||
const p = fakeProps();
|
||||
const s = fakeSequence();
|
||||
s.body.id = 1;
|
||||
const b = fakePinBinding();
|
||||
p.resources = buildResourceIndex([b, s]).index;
|
||||
p.shouldDisplay = () => true;
|
||||
p.pinBindings = [{ pin_number: 10, sequence_id: 1, uuid: b.uuid }];
|
||||
const wrapper = mount(<PinBindingsList {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
buttons.first().simulate("click");
|
||||
expect(mockDevice.unregisterGpio).not.toHaveBeenCalled();
|
||||
expect(destroy).toHaveBeenCalledWith(expect.stringContaining("PinBinding"));
|
||||
});
|
||||
|
||||
it("restricts deletion of built-in bindings", () => {
|
||||
const p = fakeProps();
|
||||
p.shouldDisplay = () => true;
|
||||
p.pinBindings = sysBtnBindingData;
|
||||
const wrapper = mount(<PinBindingsList {...p} />);
|
||||
const buttons = wrapper.find("button");
|
||||
buttons.first().simulate("click");
|
||||
expect(mockDevice.unregisterGpio).not.toHaveBeenCalled();
|
||||
expect(destroy).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Cannot delete"));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react";
|
||||
import { PinBindings } from "../pin_bindings";
|
||||
import { mount } from "enzyme";
|
||||
import { bot } from "../../../__test_support__/fake_state/bot";
|
||||
import {
|
||||
buildResourceIndex
|
||||
} from "../../../__test_support__/resource_index_builder";
|
||||
import {
|
||||
fakeSequence, fakePinBinding
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import {
|
||||
PinBindingsProps, PinBindingType, PinBindingSpecialAction, SpecialPinBinding
|
||||
} from "../interfaces";
|
||||
|
||||
describe("<PinBindings/>", () => {
|
||||
beforeEach(function () {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function fakeProps(): PinBindingsProps {
|
||||
const fakeSequence1 = fakeSequence();
|
||||
fakeSequence1.body.id = 1;
|
||||
fakeSequence1.body.name = "Sequence 1";
|
||||
const fakeSequence2 = fakeSequence();
|
||||
fakeSequence2.body.id = 2;
|
||||
fakeSequence2.body.name = "Sequence 2";
|
||||
const fakePinBinding1 = fakePinBinding();
|
||||
fakePinBinding1.body.id = 1;
|
||||
fakePinBinding1.body.pin_num = 0;
|
||||
const fakePinBinding2 = fakePinBinding();
|
||||
fakePinBinding2.body.id = 2;
|
||||
fakePinBinding2.body.pin_num = 26;
|
||||
fakePinBinding2.body.binding_type = PinBindingType.special;
|
||||
(fakePinBinding2.body as SpecialPinBinding).special_action =
|
||||
PinBindingSpecialAction.emergency_lock;
|
||||
const resources = buildResourceIndex([
|
||||
fakeSequence1, fakeSequence2, fakePinBinding1, fakePinBinding2
|
||||
]).index;
|
||||
|
||||
bot.hardware.gpio_registry = {
|
||||
10: "1",
|
||||
11: "2"
|
||||
};
|
||||
return {
|
||||
dispatch: jest.fn(),
|
||||
bot: bot,
|
||||
resources: resources,
|
||||
botToMqttStatus: "up",
|
||||
shouldDisplay: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders: bot", () => {
|
||||
const wrapper = mount(<PinBindings {...fakeProps()} />);
|
||||
["pin bindings", "pin number", "none", "bind"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
["pi gpio 10", "sequence 1", "pi gpio 11", "sequence 2"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.length).toBe(6);
|
||||
});
|
||||
|
||||
it("renders: api", () => {
|
||||
const p = fakeProps();
|
||||
p.shouldDisplay = () => true;
|
||||
const wrapper = mount(<PinBindings {...p} />);
|
||||
["pin bindings", "pin number", "none", "bind"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
["1", "16", "e-stop",
|
||||
"2", "22", "unlock",
|
||||
"26", "action"].map(string =>
|
||||
expect(wrapper.text().toLowerCase()).toContain(string));
|
||||
const buttons = wrapper.find("button");
|
||||
expect(buttons.length).toBe(8);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,71 @@
|
|||
import { BotState, ShouldDisplay } from "../interfaces";
|
||||
import { NetworkState } from "../../connectivity/interfaces";
|
||||
import { ResourceIndex } from "../../resources/interfaces";
|
||||
|
||||
export type PinBinding = StandardPinBinding | SpecialPinBinding;
|
||||
|
||||
interface PinBindingBase { id?: number; pin_num: number; }
|
||||
|
||||
export enum PinBindingType {
|
||||
special = "special",
|
||||
standard = "standard",
|
||||
}
|
||||
|
||||
interface StandardPinBinding extends PinBindingBase {
|
||||
binding_type: PinBindingType.standard;
|
||||
sequence_id: number;
|
||||
}
|
||||
|
||||
export interface SpecialPinBinding extends PinBindingBase {
|
||||
binding_type: PinBindingType.special;
|
||||
special_action: PinBindingSpecialAction;
|
||||
}
|
||||
|
||||
export enum PinBindingSpecialAction {
|
||||
emergency_lock = "emergency_lock",
|
||||
emergency_unlock = "emergency_unlock",
|
||||
sync = "sync",
|
||||
reboot = "reboot",
|
||||
power_off = "power_off",
|
||||
dump_info = "dump_info",
|
||||
read_status = "read_status",
|
||||
take_photo = "take_photo",
|
||||
}
|
||||
|
||||
export interface PinBindingsProps {
|
||||
bot: BotState;
|
||||
dispatch: Function;
|
||||
botToMqttStatus: NetworkState;
|
||||
resources: ResourceIndex;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
}
|
||||
|
||||
export interface PinBindingListItems {
|
||||
pin_number: number,
|
||||
sequence_id: number | undefined,
|
||||
special_action?: PinBindingSpecialAction | undefined,
|
||||
binding_type?: PinBindingType,
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export interface PinBindingsListProps {
|
||||
pinBindings: PinBindingListItems[];
|
||||
resources: ResourceIndex;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
dispatch: Function;
|
||||
}
|
||||
|
||||
export interface PinBindingInputGroupProps {
|
||||
dispatch: Function;
|
||||
resources: ResourceIndex;
|
||||
shouldDisplay: ShouldDisplay;
|
||||
pinBindings: PinBindingListItems[];
|
||||
}
|
||||
|
||||
export interface PinBindingInputGroupState {
|
||||
isEditing: boolean;
|
||||
pinNumberInput: number | undefined;
|
||||
sequenceIdInput: number | undefined;
|
||||
specialActionInput: PinBindingSpecialAction | undefined;
|
||||
bindingType: PinBindingType;
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
import { t } from "i18next";
|
||||
import { PinBindingType, PinBindingSpecialAction } from "./interfaces";
|
||||
import { DropDownItem } from "../../ui";
|
||||
import { gpio } from "./rpi_gpio_diagram";
|
||||
import { flattenDeep, isNumber } from "lodash";
|
||||
import { ShouldDisplay, Feature } from "../interfaces";
|
||||
|
||||
export const bindingTypeLabelLookup: { [x: string]: string } = {
|
||||
[PinBindingType.standard]: t("Sequence"),
|
||||
[PinBindingType.special]: t("Action"),
|
||||
"": t("Sequence"),
|
||||
};
|
||||
|
||||
export const bindingTypeList = (shouldDisplay: ShouldDisplay): DropDownItem[] =>
|
||||
Object.entries(bindingTypeLabelLookup)
|
||||
.filter(([value, _]) => !(value == ""))
|
||||
.filter(([value, _]) =>
|
||||
shouldDisplay(Feature.api_pin_bindings)
|
||||
|| !(value == PinBindingType.special))
|
||||
.map(([value, label]) => ({ label, value }));
|
||||
|
||||
export const specialActionLabelLookup: { [x: string]: string } = {
|
||||
[PinBindingSpecialAction.emergency_lock]: t("E-STOP"),
|
||||
[PinBindingSpecialAction.emergency_unlock]: t("UNLOCK"),
|
||||
[PinBindingSpecialAction.power_off]: t("Shutdown"),
|
||||
[PinBindingSpecialAction.reboot]: t("Reboot"),
|
||||
[PinBindingSpecialAction.sync]: t("Sync"),
|
||||
[PinBindingSpecialAction.dump_info]: t("Diagnostic Report"),
|
||||
[PinBindingSpecialAction.read_status]: t("Read Status"),
|
||||
[PinBindingSpecialAction.take_photo]: t("Take Photo"),
|
||||
"": t("None")
|
||||
};
|
||||
|
||||
export const specialActionList: DropDownItem[] =
|
||||
Object.values(PinBindingSpecialAction)
|
||||
.map((action: PinBindingSpecialAction) =>
|
||||
({ label: specialActionLabelLookup[action], value: action }));
|
||||
|
||||
/** Pin numbers for standard buttons. */
|
||||
export enum ButtonPin {
|
||||
estop = 16,
|
||||
unlock = 22,
|
||||
btn3 = 26,
|
||||
btn4 = 5,
|
||||
btn5 = 20,
|
||||
}
|
||||
|
||||
/** Pin numbers used for LED control; cannot be used in a pin binding. */
|
||||
enum LEDPin {
|
||||
sync = 24,
|
||||
connection = 25,
|
||||
led3 = 12,
|
||||
led4 = 13,
|
||||
estop = 17,
|
||||
unlock = 23,
|
||||
btn3 = 27,
|
||||
btn4 = 6,
|
||||
btn5 = 21,
|
||||
}
|
||||
|
||||
const sysLedBindings = Object.values(LEDPin);
|
||||
/** Pin numbers reserved for built-in pin bindings. */
|
||||
export const sysBtnBindings = [ButtonPin.estop, ButtonPin.unlock];
|
||||
/** All pin numbers used by FarmBot OS that cannot be used in pin bindings. */
|
||||
export const sysBindings = sysLedBindings.concat(sysBtnBindings);
|
||||
|
||||
const piI2cPins = [0, 1, 2, 3];
|
||||
/** Pin numbers used for special purposes by the RPi. (internal pullup, etc.) */
|
||||
export const reservedPiGPIO = piI2cPins;
|
||||
|
||||
const LabeledGpioPins: { [x: number]: string } = {
|
||||
[ButtonPin.estop]: "Button 1: E-STOP",
|
||||
[ButtonPin.unlock]: "Button 2: UNLOCK",
|
||||
[ButtonPin.btn3]: "Button 3",
|
||||
[ButtonPin.btn4]: "Button 4",
|
||||
[ButtonPin.btn5]: "Button 5",
|
||||
};
|
||||
|
||||
export const generatePinLabel = (pin: number) =>
|
||||
LabeledGpioPins[pin]
|
||||
? `${LabeledGpioPins[pin]} (Pi ${pin})`
|
||||
: `Pi GPIO ${pin}`;
|
||||
|
||||
/** Raspberry Pi GPIO pin numbers. */
|
||||
export const validGpioPins: number[] =
|
||||
flattenDeep(gpio)
|
||||
.filter(x => isNumber(x))
|
||||
.map((x: number) => x);
|
||||
// .filter(n => !reservedPiGPIO.includes(n));
|
||||
|
||||
/** Sort fn for pin numbers using their labels. */
|
||||
export const sortByNameAndPin = (a: number, b: number) => {
|
||||
const aLabel = generatePinLabel(a).slice(0, 8);
|
||||
const bLabel = generatePinLabel(b).slice(0, 8);
|
||||
// Sort "Button 1", "Button 2", etc.
|
||||
if (aLabel < bLabel) { return -1; }
|
||||
if (aLabel > bLabel) { return 1; }
|
||||
// Sort "GPIO Pi 4", "GPIO Pi 10", etc.
|
||||
if (a < b) { return -1; }
|
||||
if (a > b) { return 1; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** Given a list of bound pins, return a list of available pins (DDIs). */
|
||||
export const RpiPinList = (taken: number[]): DropDownItem[] =>
|
||||
validGpioPins
|
||||
.filter(n => !sysBindings.includes(n))
|
||||
.filter(n => !taken.includes(n))
|
||||
.filter(n => !reservedPiGPIO.includes(n))
|
||||
.sort(sortByNameAndPin)
|
||||
.map(n => ({ label: generatePinLabel(n), value: n }));
|
||||
|
||||
/** FarmBot OS built-in pin binding data used by Pin Bindings widget. */
|
||||
export const sysBtnBindingData = [
|
||||
{
|
||||
pin_number: ButtonPin.estop,
|
||||
sequence_id: undefined,
|
||||
special_action: PinBindingSpecialAction.emergency_lock,
|
||||
binding_type: PinBindingType.special,
|
||||
uuid: "FBOS built-in binding: emergency_lock"
|
||||
},
|
||||
{
|
||||
pin_number: ButtonPin.unlock,
|
||||
sequence_id: undefined,
|
||||
special_action: PinBindingSpecialAction.emergency_unlock,
|
||||
binding_type: PinBindingType.special,
|
||||
uuid: "FBOS built-in binding: emergency_unlock"
|
||||
},
|
||||
];
|
|
@ -0,0 +1,235 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import { Row, Col, FBSelect, NULL_CHOICE, DropDownItem } from "../../ui";
|
||||
import { PinBindingColWidth } from "./pin_bindings";
|
||||
import { Popover, Position } from "@blueprintjs/core";
|
||||
import { RpiGpioDiagram } from "./rpi_gpio_diagram";
|
||||
import {
|
||||
PinBindingType, PinBindingSpecialAction,
|
||||
PinBindingInputGroupProps, PinBindingInputGroupState
|
||||
} from "./interfaces";
|
||||
import { isNumber, includes } from "lodash";
|
||||
import { Feature, ShouldDisplay } from "../interfaces";
|
||||
import { initSave } from "../../api/crud";
|
||||
import { taggedPinBinding } from "./tagged_pin_binding_init";
|
||||
import { registerGpioPin } from "../actions";
|
||||
import { error, warning } from "farmbot-toastr";
|
||||
import {
|
||||
validGpioPins, sysBindings, generatePinLabel, RpiPinList,
|
||||
bindingTypeLabelLookup, specialActionLabelLookup, specialActionList,
|
||||
reservedPiGPIO,
|
||||
bindingTypeList
|
||||
} from "./list_and_label_support";
|
||||
import { SequenceSelectBox } from "../../sequences/sequence_select_box";
|
||||
import { ResourceIndex } from "../../resources/interfaces";
|
||||
|
||||
export class PinBindingInputGroup
|
||||
extends React.Component<PinBindingInputGroupProps, PinBindingInputGroupState> {
|
||||
state = {
|
||||
isEditing: false,
|
||||
pinNumberInput: undefined,
|
||||
sequenceIdInput: undefined,
|
||||
specialActionInput: undefined,
|
||||
bindingType: PinBindingType.standard,
|
||||
};
|
||||
|
||||
/** Validate and provide warnings about a selected pin number. */
|
||||
setSelectedPin = (pin: number | undefined) => {
|
||||
if (!includes(this.boundPins, pin)) {
|
||||
if (includes(validGpioPins, pin)) {
|
||||
this.setState({ pinNumberInput: pin });
|
||||
if (includes(reservedPiGPIO, pin)) {
|
||||
warning(t("Reserved Raspberry Pi pin may not work as expected."));
|
||||
}
|
||||
} else {
|
||||
error(t("Invalid Raspberry Pi GPIO pin number."));
|
||||
}
|
||||
} else {
|
||||
error(t("Raspberry Pi GPIO pin already bound."));
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a list of unavailable pin numbers. */
|
||||
get boundPins(): number[] {
|
||||
const userBindings = this.props.pinBindings.map(x => x.pin_number);
|
||||
return userBindings.concat(sysBindings);
|
||||
}
|
||||
|
||||
/** Validate and save a pin binding. */
|
||||
bindPin = () => {
|
||||
const { shouldDisplay, dispatch } = this.props;
|
||||
const {
|
||||
pinNumberInput, sequenceIdInput, bindingType, specialActionInput
|
||||
} = this.state;
|
||||
if (isNumber(pinNumberInput)) {
|
||||
if (bindingType && (sequenceIdInput || specialActionInput)) {
|
||||
if (shouldDisplay(Feature.api_pin_bindings)) {
|
||||
dispatch(initSave(
|
||||
bindingType == PinBindingType.special
|
||||
? taggedPinBinding({
|
||||
pin_num: pinNumberInput,
|
||||
special_action: specialActionInput,
|
||||
binding_type: bindingType
|
||||
})
|
||||
: taggedPinBinding({
|
||||
pin_num: pinNumberInput,
|
||||
sequence_id: sequenceIdInput,
|
||||
binding_type: bindingType
|
||||
})));
|
||||
} else {
|
||||
dispatch(registerGpioPin({
|
||||
pin_number: pinNumberInput,
|
||||
sequence_id: sequenceIdInput || 0
|
||||
}));
|
||||
}
|
||||
this.setState({
|
||||
pinNumberInput: undefined,
|
||||
sequenceIdInput: undefined,
|
||||
specialActionInput: undefined,
|
||||
bindingType: PinBindingType.standard,
|
||||
});
|
||||
} else {
|
||||
error(t("Please select a sequence or action."));
|
||||
}
|
||||
} else {
|
||||
error(t("Pin number cannot be blank."));
|
||||
}
|
||||
}
|
||||
|
||||
setBindingType = (ddi: { label: string, value: PinBindingType }) =>
|
||||
this.setState({
|
||||
bindingType: ddi.value,
|
||||
sequenceIdInput: undefined,
|
||||
specialActionInput: undefined
|
||||
})
|
||||
|
||||
setSequenceIdInput = (ddi: DropDownItem) =>
|
||||
this.setState({ sequenceIdInput: parseInt("" + ddi.value) })
|
||||
|
||||
setSpecialAction =
|
||||
(ddi: { label: string, value: PinBindingSpecialAction }) =>
|
||||
this.setState({ specialActionInput: ddi.value });
|
||||
|
||||
render() {
|
||||
const {
|
||||
pinNumberInput, bindingType, specialActionInput, sequenceIdInput
|
||||
} = this.state;
|
||||
const { shouldDisplay, resources } = this.props;
|
||||
|
||||
return <Row>
|
||||
<Col xs={PinBindingColWidth.pin}>
|
||||
<PinNumberInputGroup
|
||||
pinNumberInput={pinNumberInput}
|
||||
boundPins={this.boundPins}
|
||||
setSelectedPin={this.setSelectedPin} />
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.type}>
|
||||
<BindingTypeDropDown
|
||||
bindingType={bindingType}
|
||||
shouldDisplay={shouldDisplay}
|
||||
setBindingType={this.setBindingType} />
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.target}>
|
||||
{bindingType == PinBindingType.special
|
||||
? <ActionTargetDropDown
|
||||
specialActionInput={specialActionInput}
|
||||
setSpecialAction={this.setSpecialAction} />
|
||||
: <SequenceTargetDropDown
|
||||
sequenceIdInput={sequenceIdInput}
|
||||
resources={resources}
|
||||
setSequenceIdInput={this.setSequenceIdInput} />}
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.button}>
|
||||
<button
|
||||
className="fb-button green"
|
||||
type="button"
|
||||
onClick={this.bindPin} >
|
||||
{t("BIND")}
|
||||
</button>
|
||||
</Col>
|
||||
</Row>;
|
||||
}
|
||||
}
|
||||
|
||||
/** pin number selection */
|
||||
export const PinNumberInputGroup = (props: {
|
||||
pinNumberInput: number | undefined,
|
||||
boundPins: number[],
|
||||
setSelectedPin: (pin: number | undefined) => void
|
||||
}) => {
|
||||
const { pinNumberInput, boundPins, setSelectedPin } = props;
|
||||
const selectedPinNumber = isNumber(pinNumberInput) ? {
|
||||
label: generatePinLabel(pinNumberInput),
|
||||
value: "" + pinNumberInput
|
||||
} : NULL_CHOICE;
|
||||
|
||||
return <Row>
|
||||
<Col xs={1}>
|
||||
<Popover position={Position.TOP}>
|
||||
<i className="fa fa-th-large" />
|
||||
<RpiGpioDiagram
|
||||
boundPins={boundPins}
|
||||
setSelectedPin={setSelectedPin}
|
||||
selectedPin={pinNumberInput} />
|
||||
</Popover>
|
||||
</Col>
|
||||
<Col xs={9}>
|
||||
<FBSelect
|
||||
key={"pin_number_input_" + pinNumberInput}
|
||||
onChange={ddi =>
|
||||
setSelectedPin(parseInt("" + ddi.value))}
|
||||
selectedItem={selectedPinNumber}
|
||||
list={RpiPinList(boundPins)} />
|
||||
</Col>
|
||||
</Row>;
|
||||
};
|
||||
|
||||
/** binding type selection: sequence or action */
|
||||
export const BindingTypeDropDown = (props: {
|
||||
bindingType: PinBindingType,
|
||||
shouldDisplay: ShouldDisplay,
|
||||
setBindingType: (ddi: DropDownItem) => void,
|
||||
}) => {
|
||||
const { bindingType, shouldDisplay, setBindingType } = props;
|
||||
return <FBSelect
|
||||
key={"binding_type_input_" + bindingType}
|
||||
onChange={setBindingType}
|
||||
selectedItem={{
|
||||
label: bindingTypeLabelLookup[bindingType],
|
||||
value: bindingType
|
||||
}}
|
||||
list={bindingTypeList(shouldDisplay)} />;
|
||||
};
|
||||
|
||||
/** sequence selection */
|
||||
export const SequenceTargetDropDown = (props: {
|
||||
sequenceIdInput: number | undefined,
|
||||
resources: ResourceIndex,
|
||||
setSequenceIdInput: (ddi: DropDownItem) => void,
|
||||
}) => {
|
||||
const { sequenceIdInput, resources, setSequenceIdInput } = props;
|
||||
return <SequenceSelectBox
|
||||
key={sequenceIdInput}
|
||||
onChange={setSequenceIdInput}
|
||||
resources={resources}
|
||||
sequenceId={sequenceIdInput} />;
|
||||
};
|
||||
|
||||
/** special action selection */
|
||||
export const ActionTargetDropDown = (props: {
|
||||
specialActionInput: PinBindingSpecialAction | undefined,
|
||||
setSpecialAction: (ddi: DropDownItem) => void,
|
||||
}) => {
|
||||
const { specialActionInput, setSpecialAction } = props;
|
||||
|
||||
const selectedSpecialAction = specialActionInput ? {
|
||||
label: specialActionLabelLookup[specialActionInput || ""],
|
||||
value: "" + specialActionInput
|
||||
} : NULL_CHOICE;
|
||||
|
||||
return <FBSelect
|
||||
key={"special_action_input_" + specialActionInput}
|
||||
onChange={setSpecialAction}
|
||||
selectedItem={selectedSpecialAction}
|
||||
list={specialActionList} />;
|
||||
};
|
|
@ -0,0 +1,106 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import { Widget, WidgetBody, WidgetHeader, Row, Col } from "../../ui/index";
|
||||
import { ToolTips } from "../../constants";
|
||||
import { Feature } from "../interfaces";
|
||||
import { selectAllPinBindings } from "../../resources/selectors";
|
||||
import { MustBeOnline } from "../must_be_online";
|
||||
import {
|
||||
PinBinding, PinBindingSpecialAction, PinBindingType, PinBindingsProps,
|
||||
PinBindingListItems
|
||||
} from "./interfaces";
|
||||
import { sysBtnBindingData } from "./list_and_label_support";
|
||||
import { PinBindingsList } from "./pin_bindings_list";
|
||||
import { PinBindingInputGroup } from "./pin_binding_input_group";
|
||||
|
||||
/** Width of UI columns in Pin Bindings widget. */
|
||||
export enum PinBindingColWidth {
|
||||
pin = 4,
|
||||
type = 3,
|
||||
target = 4,
|
||||
button = 1
|
||||
}
|
||||
|
||||
/** Use binding type to return a sequence ID or a special action. */
|
||||
const getBindingTarget = (bindingBody: PinBinding): {
|
||||
sequence_id: number | undefined,
|
||||
special_action: PinBindingSpecialAction | undefined
|
||||
} => {
|
||||
return bindingBody.binding_type == PinBindingType.special
|
||||
? { sequence_id: undefined, special_action: bindingBody.special_action }
|
||||
: { sequence_id: bindingBody.sequence_id, special_action: undefined };
|
||||
};
|
||||
|
||||
export const PinBindings = (props: PinBindingsProps) => {
|
||||
const { dispatch, resources, shouldDisplay, botToMqttStatus, bot } = props;
|
||||
|
||||
/** Return pin binding data according to FBOS version. */
|
||||
const getPinBindings = (): PinBindingListItems[] => {
|
||||
if (shouldDisplay(Feature.api_pin_bindings)) {
|
||||
const userBindings = selectAllPinBindings(resources)
|
||||
.map(binding => {
|
||||
const { uuid, body } = binding;
|
||||
const sequence_id = getBindingTarget(body).sequence_id;
|
||||
const special_action = getBindingTarget(body).special_action;
|
||||
return {
|
||||
pin_number: body.pin_num,
|
||||
sequence_id,
|
||||
special_action,
|
||||
binding_type: body.binding_type,
|
||||
uuid: uuid
|
||||
};
|
||||
});
|
||||
return userBindings.concat(sysBtnBindingData);
|
||||
} else {
|
||||
const { gpio_registry } = props.bot.hardware;
|
||||
return Object.entries(gpio_registry || {})
|
||||
.map(([pin_number, sequence_id]) => {
|
||||
return {
|
||||
pin_number: parseInt(pin_number),
|
||||
sequence_id: parseInt(sequence_id || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return <Widget className="pin-bindings-widget">
|
||||
<WidgetHeader
|
||||
title={t("Pin Bindings")}
|
||||
helpText={ToolTips.PIN_BINDINGS} />
|
||||
<WidgetBody>
|
||||
<MustBeOnline
|
||||
syncStatus={bot.hardware.informational_settings.sync_status}
|
||||
networkState={botToMqttStatus}
|
||||
lockOpen={shouldDisplay(Feature.api_pin_bindings)
|
||||
|| process.env.NODE_ENV !== "production"}>
|
||||
<Row>
|
||||
<Col xs={PinBindingColWidth.pin}>
|
||||
<label>
|
||||
{t("Pin Number")}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.type}>
|
||||
<label>
|
||||
{t("Binding")}
|
||||
</label>
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.target}>
|
||||
<label>
|
||||
{t("target")}
|
||||
</label>
|
||||
</Col>
|
||||
</Row>
|
||||
<PinBindingsList
|
||||
pinBindings={getPinBindings()}
|
||||
dispatch={dispatch}
|
||||
resources={resources}
|
||||
shouldDisplay={shouldDisplay} />
|
||||
<PinBindingInputGroup
|
||||
pinBindings={getPinBindings()}
|
||||
dispatch={dispatch}
|
||||
resources={resources}
|
||||
shouldDisplay={shouldDisplay} />
|
||||
</MustBeOnline>
|
||||
</WidgetBody>
|
||||
</Widget>;
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import * as React from "react";
|
||||
import { t } from "i18next";
|
||||
import { Feature } from "../interfaces";
|
||||
import {
|
||||
sysBtnBindings, bindingTypeLabelLookup, specialActionLabelLookup,
|
||||
generatePinLabel, sortByNameAndPin
|
||||
} from "./list_and_label_support";
|
||||
import { destroy } from "../../api/crud";
|
||||
import { error } from "farmbot-toastr";
|
||||
import { Row, Col } from "../../ui";
|
||||
import { findSequenceById } from "../../resources/selectors";
|
||||
import { unregisterGpioPin } from "../actions";
|
||||
import { PinBindingColWidth } from "./pin_bindings";
|
||||
import { PinBindingsListProps } from "./interfaces";
|
||||
|
||||
export const PinBindingsList = (props: PinBindingsListProps) => {
|
||||
const { pinBindings, resources, shouldDisplay, dispatch } = props;
|
||||
|
||||
const deleteBinding = (pin: number, uuid?: string) => {
|
||||
if (shouldDisplay(Feature.api_pin_bindings)) {
|
||||
if (!sysBtnBindings.includes(pin)) {
|
||||
dispatch(destroy(uuid || ""));
|
||||
} else {
|
||||
error(t("Cannot delete built-in pin binding."));
|
||||
}
|
||||
} else {
|
||||
dispatch(unregisterGpioPin(pin));
|
||||
}
|
||||
};
|
||||
|
||||
const delBtnColor = (pin: number) =>
|
||||
sysBtnBindings.includes(pin) ? "pseudo-disabled" : "red";
|
||||
|
||||
return <div className={"bindings-list"}>
|
||||
{pinBindings
|
||||
.sort((a, b) => sortByNameAndPin(a.pin_number, b.pin_number))
|
||||
.map(x => {
|
||||
const { pin_number, sequence_id, binding_type, special_action } = x;
|
||||
return <Row key={`pin_${pin_number}_binding`}>
|
||||
<Col xs={PinBindingColWidth.pin}>
|
||||
{generatePinLabel(pin_number)}
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.type}>
|
||||
{t(bindingTypeLabelLookup[binding_type || ""])}
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.target}>
|
||||
{sequence_id
|
||||
? findSequenceById(resources, sequence_id).body.name
|
||||
: t(specialActionLabelLookup[special_action || ""])}
|
||||
</Col>
|
||||
<Col xs={PinBindingColWidth.button}>
|
||||
<button
|
||||
className={`fb-button ${delBtnColor(pin_number)}`}
|
||||
onClick={() => deleteBinding(pin_number, x.uuid)}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</Col>
|
||||
</Row>;
|
||||
})}
|
||||
</div>;
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from "react";
|
||||
import { Color } from "../../ui/colors";
|
||||
import * as _ from "lodash";
|
||||
import { reservedPiGPIO } from "./list_and_label_support";
|
||||
|
||||
export interface RpiGpioDiagramProps {
|
||||
boundPins: number[] | undefined;
|
||||
|
@ -55,7 +56,7 @@ export class RpiGpioDiagram extends React.Component<RpiGpioDiagramProps, RpiGpio
|
|||
{[3, 5.5].map((x, xi) => {
|
||||
return _.range(8, 56, 2.5).map((y, yi) => {
|
||||
const pin = gpio[yi][xi];
|
||||
const color = () => {
|
||||
const normalColor = () => {
|
||||
switch (pin) {
|
||||
case "GND":
|
||||
return Color.black;
|
||||
|
@ -70,9 +71,12 @@ export class RpiGpioDiagram extends React.Component<RpiGpioDiagramProps, RpiGpio
|
|||
return Color.green;
|
||||
}
|
||||
};
|
||||
const color = _.isNumber(pin) && reservedPiGPIO.includes(pin)
|
||||
? Color.magenta
|
||||
: normalColor();
|
||||
const pinColor = _.includes(this.props.boundPins, pin)
|
||||
? Color.darkGray
|
||||
: color();
|
||||
: color;
|
||||
return <rect strokeWidth={0.5} key={`gpio_${pin}_${xi}_${yi}`}
|
||||
stroke={pinColor} fill={pinColor}
|
||||
x={x} y={y} width={1.5} height={1.5}
|
|
@ -0,0 +1,32 @@
|
|||
import { PinBindingType, PinBindingSpecialAction, PinBinding } from "./interfaces";
|
||||
import { TaggedPinBinding, SpecialStatus } from "../../resources/tagged_resources";
|
||||
|
||||
/** Return the correct Pin Binding resource according to binding type. */
|
||||
export const taggedPinBinding =
|
||||
(bodyInputs: {
|
||||
pin_num: number,
|
||||
binding_type: PinBindingType,
|
||||
sequence_id?: number | undefined,
|
||||
special_action?: PinBindingSpecialAction | undefined
|
||||
}): TaggedPinBinding => {
|
||||
const { pin_num, binding_type, special_action, sequence_id } = bodyInputs;
|
||||
const body: PinBinding =
|
||||
binding_type == PinBindingType.special
|
||||
? {
|
||||
pin_num,
|
||||
binding_type,
|
||||
special_action: special_action
|
||||
|| PinBindingSpecialAction.emergency_lock,
|
||||
}
|
||||
: {
|
||||
pin_num,
|
||||
binding_type,
|
||||
sequence_id: sequence_id || 0,
|
||||
};
|
||||
return {
|
||||
uuid: "WILL_BE_CHANGED_BY_REDUCER",
|
||||
specialStatus: SpecialStatus.SAVED,
|
||||
kind: "PinBinding",
|
||||
body
|
||||
};
|
||||
};
|
|
@ -66,6 +66,7 @@ describe("<FarmwareInfo />", () => {
|
|||
p.farmware.name = "Fake 1st-Party Farmware";
|
||||
p.firstPartyFarmwareNames = ["Fake 1st-Party Farmware"];
|
||||
const wrapper = mount(<FarmwareInfo {...p} />);
|
||||
window.confirm = jest.fn(() => false);
|
||||
clickButton(wrapper, 1, "Remove");
|
||||
expect(mockDevice.removeFarmware).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -79,10 +79,12 @@ describe("<FarmwarePage />", () => {
|
|||
);
|
||||
it("renders installed Farmware page", () => {
|
||||
const p = fakeProps();
|
||||
p.farmwares["My Fake Farmware"] = fakeFarmware();
|
||||
p.currentFarmware = "My Fake Farmware";
|
||||
const farmware = fakeFarmware();
|
||||
farmware.name = "My Fake Test Farmware";
|
||||
p.farmwares["My Fake Test Farmware"] = farmware;
|
||||
p.currentFarmware = "My Fake Test Farmware";
|
||||
const wrapper = mount(<FarmwarePage {...p} />);
|
||||
["My Fake Farmware", "Does things", "Run", "Config 1",
|
||||
["My Fake Test Farmware", "Does things", "Run", "Config 1",
|
||||
"Information", "Description", "Version", "Update", "Remove"
|
||||
].map(string =>
|
||||
expect(wrapper.text()).toContain(string));
|
||||
|
@ -92,8 +94,9 @@ describe("<FarmwarePage />", () => {
|
|||
const p = fakeProps();
|
||||
const farmware = fakeFarmware();
|
||||
farmware.config = [];
|
||||
p.farmwares["My Fake Farmware"] = farmware;
|
||||
p.currentFarmware = "My Fake Farmware";
|
||||
farmware.name = "My Fake Test Farmware";
|
||||
p.farmwares["My Fake Test Farmware"] = farmware;
|
||||
p.currentFarmware = "My Fake Test Farmware";
|
||||
const wrapper = mount(<FarmwarePage {...p} />);
|
||||
["My Fake Farmware", "Does things", "Run", "No inputs provided."
|
||||
].map(string =>
|
||||
|
@ -104,10 +107,11 @@ describe("<FarmwarePage />", () => {
|
|||
const p = fakeProps();
|
||||
const farmware = fakeFarmware();
|
||||
farmware.config = [];
|
||||
p.farmwares["My Fake Farmware"] = farmware;
|
||||
p.currentFarmware = "My Fake Farmware";
|
||||
farmware.name = "My Fake Test Farmware";
|
||||
p.farmwares["My Fake Test Farmware"] = farmware;
|
||||
p.currentFarmware = "My Fake Test Farmware";
|
||||
const wrapper = mount(<FarmwarePage {...p} />);
|
||||
clickButton(wrapper, 1, "Run");
|
||||
expect(mockDevice.execScript).toHaveBeenCalledWith("My Fake Farmware");
|
||||
expect(mockDevice.execScript).toHaveBeenCalledWith("My Fake Test Farmware");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,20 +11,6 @@ import { ChannelName } from "./sequences/interfaces";
|
|||
in the UI. Only certain colors are valid. */
|
||||
export type Color = FarmBotJsColor;
|
||||
|
||||
export type PinBinding = StandardPinBinding | SpecialPinBinding;
|
||||
|
||||
interface PinBindingBase { id?: number; pin_num: number; }
|
||||
|
||||
interface StandardPinBinding extends PinBindingBase {
|
||||
binding_type: "standard";
|
||||
sequence_id: number;
|
||||
}
|
||||
|
||||
export interface SpecialPinBinding extends PinBindingBase {
|
||||
binding_type: "special";
|
||||
special_action: string; // TODO: Maybe use enum? RC 15 JUL 18
|
||||
}
|
||||
|
||||
export interface Sensor {
|
||||
id?: number;
|
||||
pin: number | undefined;
|
||||
|
|
|
@ -10,7 +10,6 @@ import {
|
|||
SensorReading,
|
||||
Sensor,
|
||||
DeviceConfig,
|
||||
PinBinding
|
||||
} from "../interfaces";
|
||||
import { Peripheral } from "../controls/peripherals/interfaces";
|
||||
import { User } from "../auth/interfaces";
|
||||
|
@ -25,6 +24,7 @@ import { FirmwareConfig } from "../config_storage/firmware_configs";
|
|||
import { WebAppConfig } from "../config_storage/web_app_configs";
|
||||
import { FarmwareInstallation } from "../farmware/interfaces";
|
||||
import { assertUuid } from "./util";
|
||||
import { PinBinding } from "../devices/pin_bindings/interfaces";
|
||||
|
||||
export type ResourceName =
|
||||
| "Crop"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import axios from "axios";
|
||||
import { Log, Point, SensorReading, Sensor, DeviceConfig, PinBinding } from "../interfaces";
|
||||
import { Log, Point, SensorReading, Sensor, DeviceConfig } from "../interfaces";
|
||||
import { API } from "../api";
|
||||
import { Sequence } from "../sequences/interfaces";
|
||||
import { Tool } from "../tools/interfaces";
|
||||
|
@ -16,6 +16,7 @@ import { Session } from "../session";
|
|||
import { FbosConfig } from "../config_storage/fbos_configs";
|
||||
import { FarmwareInstallation } from "../farmware/interfaces";
|
||||
import { FirmwareConfig } from "../config_storage/firmware_configs";
|
||||
import { PinBinding } from "../devices/pin_bindings/interfaces";
|
||||
|
||||
export interface ResourceReadyPayl {
|
||||
name: ResourceName;
|
||||
|
|
|
@ -16,4 +16,5 @@ export enum Color {
|
|||
black = "#000000",
|
||||
orange = "#ffa500",
|
||||
blue = "#3377dd",
|
||||
magenta = "#a64d79",
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue