Merge conflict bloopers

pull/918/head
Rick Carlino 2018-07-19 10:30:49 -05:00
commit 33150772e6
70 changed files with 1864 additions and 522 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ public/webpack/*
tmp
public/direct_upload/temp/*.jpg
scratchpad.rb
*scratchpad*

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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") { "/" }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
module DiagnosticDumps
Destroy = CreateDestroyer.run!(resource: DiagnosticDump)
end

View File

@ -0,0 +1,3 @@
module FarmEvents
Destroy = CreateDestroyer.run!(resource: FarmEvent)
end

View File

@ -0,0 +1,3 @@
module FarmwareInstallations
Destroy = CreateDestroyer.run!(resource: FarmwareInstallation)
end

View File

@ -0,0 +1,3 @@
module Images
Destroy = CreateDestroyer.run!(resource: Image)
end

View File

@ -0,0 +1,3 @@
module Logs
Destroy = CreateDestroyer.run!(resource: Log)
end

View File

@ -0,0 +1,3 @@
module PlantTemplates
Destroy = CreateDestroyer.run!(resource: PlantTemplate)
end

View File

@ -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

View File

@ -20,3 +20,4 @@ module Regimens
end
end
end
Regimina ||= Regimens # Lol, inflection errors

View File

@ -17,3 +17,5 @@ module Regimens
end
end
end
Regimina ||= Regimens # Lol, inflection errors

View File

@ -32,3 +32,4 @@ module Regimens
end
end
end
Regimina ||= Regimens # Lol, inflection errors

View File

@ -0,0 +1,3 @@
module SavedGardens
Destroy = CreateDestroyer.run!(resource: SavedGarden)
end

View File

@ -0,0 +1,3 @@
module SensorReadings
Destroy = CreateDestroyer.run!(resource: SensorReading)
end

View File

@ -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"

View File

@ -0,0 +1,4 @@
module WebcamFeeds
Destroy = CreateDestroyer.run!(resource: WebcamFeed,
singular_name: "webcam_feed")
end

78
batch_updates.md 100644
View File

@ -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" }
}
]
}
```

View File

@ -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

View File

@ -0,0 +1,3 @@
require_relative "../app/lib/service_runner_base.rb"
ServiceRunner.go!(Transport.current.log_channel, LogService)

View File

@ -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)

View File

@ -1,5 +0,0 @@
require 'spec_helper'
describe CleanOutOldDbItemsJob, type: :job do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,5 +0,0 @@
require "spec_helper"
describe DataDumpMailer, type: :mailer do
it 'sends a JSON file to users'
end

View File

@ -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

View File

@ -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,
});
}

View File

@ -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()
}));

View File

@ -49,6 +49,7 @@ describe("destroy", () => {
};
it("not confirmed", () => {
window.confirm = () => false;
expect(fakeDestroy()).rejects.toEqual("User pressed cancel");
expectNotDestroyed();
});

View File

@ -302,6 +302,7 @@ a {
}
.bindings-list {
margin-bottom: 1rem;
font-size: 1.2rem;
}
}

View File

@ -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_");
});
});

View File

@ -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);
});
});

View File

@ -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, {}> {

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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>;
}
}

View File

@ -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)

View File

@ -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
});
});

View File

@ -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);
});
});

View File

@ -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"));
});
});

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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"
},
];

View File

@ -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} />;
};

View File

@ -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>;
};

View File

@ -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>;
};

View File

@ -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}

View File

@ -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
};
};

View File

@ -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();
});

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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"

View File

@ -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;

View File

@ -16,4 +16,5 @@ export enum Color {
black = "#000000",
orange = "#ffa500",
blue = "#3377dd",
magenta = "#a64d79",
}