Merge branch 'staging' into master

pull/833/head
Diman4os 2018-05-06 12:21:36 +03:00 committed by GitHub
commit e27d149e43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
167 changed files with 1547 additions and 970 deletions

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ public/system
public/webpack
public/webpack/*
tmp
public/direct_upload/temp/*.jpg

View File

@ -7,8 +7,6 @@ before_script:
- rvm install 2.5.1 && rvm use 2.5.1
- mv node_modules/.bin/which.backup node_modules/.bin/which
- cp config/database.travis.yml config/database.yml
- mkdir -p public/app
- touch public/app/index.html
- bundle install
- bundle exec rake db:create db:migrate
- yarn install

View File

@ -90,7 +90,7 @@ private
response.headers[REQ_ID] = id
# # IMPORTANT: We need to hoist X-Farmbot-Rpc-Id to a global so that it is
# # accessible for use with auto_sync.
Transport.set_current_request_id(response.headers[REQ_ID])
Transport.current.set_current_request_id(response.headers[REQ_ID])
end
# Disable cookies. This is an API!

View File

@ -2,6 +2,8 @@
# settings. Consumed by the Angular SPA on the front end.
module Api
class DevicesController < Api::AbstractController
cattr_accessor :send_emails
self.send_emails = !ENV["NO_EMAILS"]
# GET /api/device
def show
@ -25,7 +27,27 @@ module Api
end
def dump
send_emails ? email_data_dump : store_data_dump
end
private
# Store the JSON on the local filesystem for self hosted users that don't
# have email set up.
#
# Q: Why not just return JSON all the time instead of emailing?
#
# A: Querying and serializing every single resource a device has is
# expensive. Slow requests will bog down requests for all users.
# If the server has email enabled, it's better to do the work in a
# background process.
def store_data_dump
mutate Devices::Dump.run(device: current_device)
end
def email_data_dump
DataDumpMailer.email_json_dump(current_device).deliver_later
render json: nil, status: 202 # "Accepted"
end
end
end

View File

@ -4,12 +4,11 @@ module Api
# 2. POST the URL from step 1 (or any URL) to ImagesController#Create
# 3. Image is transfered to the "trusted bucket".
class ImagesController < Api::AbstractController
BUCKET = ENV.fetch("GCS_BUCKET") { "YOU_MUST_CONFIG_GOOGLE_CLOUD_STORAGE" }
KEY = ENV.fetch("GCS_KEY") { "YOU_MUST_CONFIG_GCS_KEY" }
SECRET = ENV.fetch("GCS_ID") { "YOU_MUST_CONFIG_GCS_ID" }
cattr_accessor :store_locally
self.store_locally = !ENV["GCS_BUCKET"]
def create
mutate Images::Create.run(raw_json, device: current_device)
mutate Images::Create.run(raw_json, device: current_device)
end
def index
@ -28,51 +27,17 @@ module Api
# Creates a "policy object" + meta data so that users may upload an image to
# Google Cloud Storage.
def storage_auth
# Creates a 1 hour authorization for a user to upload an image file to a
# Google Cloud Storage bucket.
# You probably want to POST that URL to Images#Create after that.
render json: {
verb: "POST",
url: "//storage.googleapis.com/#{BUCKET}/",
form_data: {
"key" => random_filename,
"acl" => "public-read",
"Content-Type" => "image/jpeg",
"policy" => policy,
"signature" => policy_signature,
"GoogleAccessId" => KEY,
"file" => "REPLACE_THIS_WITH_A_BINARY_JPEG_FILE"
},
instructions: "Send a 'from-data' request to the URL provided."\
"Then POST the resulting URL as an 'attachment_url' "\
"(json) to api/images/."
}
mutate policy_class.run
end
private
# The image URL in the "untrusted bucket" in Google Cloud Storage
def random_filename
@range ||= "temp1/#{SecureRandom.uuid}.jpg"
end
def policy
@policy ||= Base64.encode64(
{ 'expiration' => 1.hour.from_now.utc.xmlschema,
'conditions' => [
{ 'bucket' => BUCKET },
{ 'key' => random_filename},
{ 'acl' => 'public-read' },
{ 'Content-Type' => "image/jpeg"},
['content-length-range', 1, 7.megabytes]
]}.to_json).gsub(/\n/, '')
end
def policy_signature
@policy_signature ||= Base64.encode64(
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'),
SECRET,
policy)).gsub("\n",'')
def policy_class
if ImagesController.store_locally
Images::GeneratePolicy
else
Images::StubPolicy
end
end
def image

View File

@ -9,7 +9,9 @@ module Api
end
def update
mutate SavedGardens::Update.run(raw_json, saved_garden: garden, device: current_device)
mutate SavedGardens::Update.run(raw_json,
saved_garden: garden,
device: current_device)
end
def destroy
@ -21,8 +23,8 @@ module Api
end
def apply
params = { garden: garden,
device: current_device,
params = { garden: garden,
device: current_device,
destructive: (request.method == "POST") }
mutate SavedGardens::Apply.run(params)
end

View File

@ -1,14 +1,5 @@
module Api
class ToolsController < Api::AbstractController
INDEX_QUERY = 'SELECT
"tools".*,
points.id as tool_slot_id
FROM
"tools"
LEFT OUTER JOIN
"points" ON "points"."tool_id" = "tools"."id"
WHERE
"tools"."device_id" = %s;'
def index
render json: tools
@ -39,11 +30,11 @@ private
end
def tools
Tool.find_by_sql(INDEX_QUERY % current_device.id)
@tools ||= Tool.outter_join_slots(current_device.id)
end
def tool
@tool ||= Tool.where(device: current_device).find(params[:id])
@tool ||= Tool.join_tool_slot_and_find_by_id(params[:id])
end
end
end

View File

@ -46,6 +46,16 @@ class DashboardController < ApplicationController
render json: report
end
# (for self hosted users) Direct image upload endpoint.
# Do not use this if you use GCS- it will slow your app down.
def direct_upload
raise "No." unless Api::ImagesController.store_locally
name = params.fetch(:key).split("/").last
path = File.join("public", "direct_upload", "temp", name)
File.open(path, "wb") { |f| f.write(params[:file]) }
render json: ""
end
private
def set_global_config

View File

@ -5,6 +5,6 @@ class AutoSyncJob < ApplicationJob
wayback = Time.at(created_at_utc_integer).utc
mins = ((wayback - Time.now.utc) / 1.minute).round
Transport.amqp_send(broadcast_payload, id, channel) if (mins < 2)
Transport.current.amqp_send(broadcast_payload, id, channel) if (mins < 2)
end
end

View File

@ -9,6 +9,6 @@ class SendFactoryResetJob < ApplicationJob
def perform(device, transport = Transport)
payl = SendFactoryResetJob.rpc_payload(device)
transport.amqp_send(payl.to_json, device.id, "from_clients")
transport.current.amqp_send(payl.to_json, device.id, "from_clients")
end
end

View File

@ -5,10 +5,6 @@ class Pair
attr_accessor :head, :tail
def self.[](h,t)
self.new(h,t)
end
def initialize(h, t)
@head, @tail = h, t
end

View File

@ -40,7 +40,6 @@ class SessionToken < AbstractJwtToken
mqtt_ws: MQTT_WS,
os_update_server: url,
interim_email: user.email, # Dont use this for anything ever -RC
fw_update_server: "DEPRECATED",
beta_os_update_server: BETA_OS_URL }])
end

View File

@ -0,0 +1,22 @@
class DataDumpMailer < ApplicationMailer
SUBJECT = "FarmBot Account Data Export"
EXPORT_MIME_TYPE = "application/json"
EXPORT_FILENAME = "export.json"
attr_reader :device
def email_json_dump(device)
@device = device
attachments[EXPORT_FILENAME] = \
{ mime_type: EXPORT_MIME_TYPE, content: export_data }
mail to: recipients, subject: SUBJECT
end
def recipients
@recipients ||= device.users.pluck(:email)
end
def export_data
@export_data ||= Devices::Dump.run!(device: device).to_json
end
end

View File

@ -46,7 +46,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def broadcast_payload
{ args: { label: Transport.current_request_id }, body: body_as_json }.to_json
{ args: { label: Transport.current.current_request_id }, body: body_as_json }.to_json
end
# Overridable

View File

@ -9,20 +9,24 @@ class Device < ApplicationRecord
has_many :device_configs, dependent: :destroy
has_many :farm_events, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :farmware_installations, dependent: :destroy
has_many :images, dependent: :destroy
has_many :logs, dependent: :destroy
has_many :peripherals, dependent: :destroy
has_many :pin_bindings, dependent: :destroy
has_many :points, dependent: :destroy
has_many :plant_templates, dependent: :destroy
has_many :points, dependent: :destroy
has_many :regimens, dependent: :destroy
has_many :saved_gardens, dependent: :destroy
has_many :sensor_readings, dependent: :destroy
has_many :sensors, dependent: :destroy
has_many :sequences, dependent: :destroy
has_many :token_issuances, dependent: :destroy
has_many :tools, dependent: :destroy
has_many :webcam_feeds, dependent: :destroy
has_one :fbos_config, dependent: :destroy
has_many :in_use_tools
has_many :in_use_points
has_many :users
validates_presence_of :name
@ -65,7 +69,7 @@ class Device < ApplicationRecord
end
# Send a realtime message to a logged in user.
def tell(message, transport = Transport)
def tell(message)
log = Log.new({ device: self,
message: message,
created_at: Time.now,
@ -73,7 +77,7 @@ class Device < ApplicationRecord
meta: { type: "info" } })
json = LogSerializer.new(log).as_json.to_json
transport.amqp_send(json, self.id, "logs")
Transport.current.amqp_send(json, self.id, "logs")
log
end

View File

@ -1,6 +1,8 @@
# THIS IS A SQL VIEW. IT IS NOT A REAL TABLE.
# Maps Point <==> Sequence
class InUsePoint < ApplicationRecord
belongs_to :device
DEFAULT_NAME = "point"
FANCY_NAMES = {
GenericPointer.name => DEFAULT_NAME,
@ -15,5 +17,4 @@ class InUsePoint < ApplicationRecord
def fancy_name
"#{FANCY_NAMES[pointer_type] || DEFAULT_NAME} at (#{x}, #{y}, #{z})"
end
end

View File

@ -1,6 +1,8 @@
# THIS IS A SQL VIEW. IT IS NOT A REAL TABLE.
# Maps Tool <==> Sequence
class InUseTool < ApplicationRecord
belongs_to :device
def readonly?
true
end

View File

@ -1,5 +0,0 @@
class LegacyGenericPointer < ApplicationRecord
def broadcast?
false
end
end

View File

@ -1,5 +0,0 @@
class LegacyPlant < ApplicationRecord
def broadcast?
false
end
end

View File

@ -1,5 +0,0 @@
class LegacyToolSlot < ApplicationRecord
def broadcast?
false
end
end

View File

@ -30,7 +30,7 @@ class Log < ApplicationRecord
end
# Legacy shims ===============================================================
# TODO: Remove these once FBOS stops using the `meta` field.
# TODO: Remove these once FBOS stops using the `meta` field (FBOS < v6.4.0).
def meta
{
type: self.type,

View File

@ -1,11 +1,4 @@
# A single organism living in the ground.
class Plant < Point
DEFAULT_ICON = "/app-resources/img/icons/generic-plant.svg"
def do_migrate
legacy = LegacyPlant.find(self[:pointer_id])
self.update_attributes!(migrated_at: Time.now,
openfarm_slug: legacy.openfarm_slug,
plant_stage: legacy.plant_stage,
pointer_type: "Plant")
end
end

View File

@ -8,29 +8,12 @@ class Point < ApplicationRecord
belongs_to :device
validates_presence_of :device
after_find :maybe_migrate
after_discard :maybe_broadcast
[:x, :y, :z].map do |axis|
validates_numericality_of axis, less_than: MAX_AXIS_SIZE
end
def should_migrate?
self.id && !self.migrated_at && (self.pointer_id != 0) && !is_infinite?
end
def is_infinite? # Values of `infinity` will crash the migration process.
[x,y,z].compact.map(&:infinite?).compact.present?
end
def maybe_migrate
do_migrate if should_migrate?
end
def do_migrate
# ABSTRACT METHOD.
end
def name_used_when_syncing
"Point"
end

View File

@ -2,10 +2,32 @@
# confused with Peripherals, which keep track of electronic data such as pin
# modes.
class Tool < ApplicationRecord
BASE = 'SELECT
"tools".*,
points.id as tool_slot_id
FROM
"tools"
LEFT OUTER JOIN
"points" ON "points"."tool_id" = "tools"."id"
WHERE'
INDEX_QUERY = BASE + ' "tools"."device_id" = %s;'
SHOW_QUERY = BASE + ' "tools"."id" = %s;'
IN_USE = "Tool in use by the following sequences: %s"
belongs_to :device
has_one :tool_slot
validates :device, presence: true
validates :name, uniqueness: { scope: :device }
IN_USE = "Tool in use by the following sequences: %s"
def self.outter_join_slots(device_id)
self.find_by_sql(INDEX_QUERY % device_id)
end
def self.join_tool_slot_and_find_by_id(id)
# Adding the || self.find part to raise 404 like "normal" AR queries.
# TODO: Clean this whole thing up - RC 2-may-18
self.find_by_sql(SHOW_QUERY % id).first || self.find(id)
end
end

View File

@ -11,7 +11,7 @@ class ToolSlot < Point
MIN_PULLOUT = PULLOUT_DIRECTIONS.min
PULLOUT_ERR = "must be a value between #{MIN_PULLOUT} and #{MAX_PULLOUT}. "\
"%{value} is not valid."
IN_USE = "already in use by another tool slot"
IN_USE = "already in use by another tool slot"
belongs_to :tool
validates_uniqueness_of :tool,
@ -21,15 +21,4 @@ class ToolSlot < Point
validates :pullout_direction,
presence: true,
inclusion: { in: PULLOUT_DIRECTIONS, message: PULLOUT_ERR }
def do_migrate
LegacyToolSlot.transaction do
legacy = LegacyToolSlot.find(self[:pointer_id])
self.update_attributes!(migrated_at: Time.now,
pullout_direction: legacy.pullout_direction,
tool_id: legacy.tool_id,
pointer_type: "ToolSlot")
legacy.destroy!
end
end
end

View File

@ -1,40 +1,58 @@
require "bunny"
# A wrapper around AMQP to stay DRY. Will make life easier if we ever need to
# change protocols
module Transport
class Transport
LOCAL = "amqp://guest:guest@localhost:5672"
AMQP_URL = ENV['CLOUDAMQP_URL'] || ENV['RABBITMQ_URL'] || LOCAL
OPTS = { read_timeout: 10, heartbeat: 10, log_level: 'info' }
def self.connection
@connection ||= Bunny.new(AMQP_URL, OPTS).start
def self.default_amqp_adapter=(value)
@default_amqp_adapter = value
end
def self.log_channel
def self.default_amqp_adapter
@default_amqp_adapter ||= Bunny
end
attr_accessor :amqp_adapter, :request_store
def self.current
@current ||= self.new
end
def self.current=(value)
@current = value
end
def connection
@connection ||= Transport.default_amqp_adapter.new(AMQP_URL, OPTS).start
end
def log_channel
@log_channel ||= self.connection
.create_channel
.queue("", exclusive: true)
.bind("amq.topic", routing_key: "bot.*.logs")
end
def self.topic
@topic ||= self
def amqp_topic
@amqp_topic ||= self
.connection
.create_channel
.topic("amq.topic", auto_delete: true)
end
def self.amqp_send(message, id, channel)
topic.publish(message, routing_key: "bot.device_#{id}.#{channel}")
def amqp_send(message, id, channel)
amqp_topic.publish(message, routing_key: "bot.device_#{id}.#{channel}")
end
# We need to hoist the Rack X-Farmbot-Rpc-Id to a global state so that it can
# be used as a unique identifier for AMQP messages.
def self.current_request_id
def current_request_id
RequestStore.store[:current_request_id] || "NONE"
end
def self.set_current_request_id(uuid)
def set_current_request_id(uuid)
RequestStore.store[:current_request_id] = uuid
end
end

View File

@ -1,33 +1,32 @@
module Devices
# DO NOT USE THIS IN YOUR APPLICATION! = = = = = =
# Download all your account data as a single JSON document. Used by the dev
# team to debug data related issues (especially those that are specific to a
# particular server or database).
# DO NOT USE THIS IN YOUR APPLICATION! = = = = = =
class Dump < Mutations::Command
RESOURCES = [
Pair[:images, ImageSerializer],
Pair[:regimens, RegimenSerializer],
Pair[:peripherals, PeripheralSerializer],
# Pair[:sequences, SequenceSerializer],
Pair[:farm_events, FarmEventSerializer],
# Pair[:tools, ToolSerializer],
# Pair[:points, PointSerializer],
Pair[:users, UserSerializer],
Pair[:webcam_feeds, WebcamFeedSerializer]
]
RESOURCES = [ :device_configs, :farm_events, :farmware_installations,
:images, :logs, :peripherals, :pin_bindings, :plant_templates, :points,
:regimens, :saved_gardens, :sensor_readings, :sensors, :sequences,
:token_issuances, :users, :webcam_feeds ]
required do
model :device, class: Device
end
required { model :device, class: Device }
def execute
output = { device: DeviceSerializer.new(device).as_json }
RESOURCES.map do |pair|
list = device.send(pair.head)
output[pair.head] = list.map { |x| pair.tail.new(x).as_json }
RESOURCES.each do |name|
model = device.send(name)
output[name] = \
model.try(:map) { |x| x.body_as_json } || x.body_as_json
end
output
end
private
def output
@output ||= {
# Tools show up as "inactive" if you don't do this.
tools: Tool.outter_join_slots(device.id).map(&:body_as_json),
device: device.body_as_json,
fbos_config: device.fbos_config,
firmware_config: device.firmware_config,
web_app_config: device.web_app_config
}
end
end
end

View File

@ -0,0 +1,50 @@
module Images
class GeneratePolicy < Mutations::Command
BUCKET = ENV.fetch("GCS_BUCKET") { "YOU_MUST_CONFIG_GOOGLE_CLOUD_STORAGE" }
KEY = ENV.fetch("GCS_KEY") { "YOU_MUST_CONFIG_GCS_KEY" }
SECRET = ENV.fetch("GCS_ID") { "YOU_MUST_CONFIG_GCS_ID" }
def execute
{
verb: "POST",
url: "//storage.googleapis.com/#{BUCKET}/",
form_data: {
"key" => random_filename,
"acl" => "public-read",
"Content-Type" => "image/jpeg",
"policy" => policy,
"signature" => policy_signature,
"GoogleAccessId" => KEY,
"file" => "REPLACE_THIS_WITH_A_BINARY_JPEG_FILE"
},
instructions: "Send a 'from-data' request to the URL provided."\
"Then POST the resulting URL as an 'attachment_url' "\
"(json) to api/images/."
}
end
private
# The image URL in the "untrusted bucket" in Google Cloud Storage
def random_filename
@range ||= "temp1/#{SecureRandom.uuid}.jpg"
end
def policy
@policy ||= Base64.encode64(
{ 'expiration' => 1.hour.from_now.utc.xmlschema,
'conditions' => [
{ 'bucket' => BUCKET },
{ 'key' => random_filename},
{ 'acl' => 'public-read' },
{ 'Content-Type' => "image/jpeg"},
['content-length-range', 1, 7.megabytes]
]}.to_json).gsub(/\n/, '')
end
def policy_signature
@policy_signature ||= Base64.encode64(
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha1'),
SECRET,
policy)).gsub("\n",'')
end
end
end

View File

@ -0,0 +1,29 @@
module Images
class StubPolicy < Mutations::Command
URL = "#{$API_URL}/direct_upload/"
def execute
{
verb: "POST",
url: URL,
form_data: {
"key" => random_filename,
"acl" => "public-read",
"Content-Type" => "image/jpeg",
"policy" => "N/A",
"signature" => "N/A",
"GoogleAccessId" => "N/A",
"file" => "REPLACE_THIS_WITH_A_BINARY_JPEG_FILE"
},
instructions: "Send a 'from-data' request to the URL provided."\
"Then POST the resulting URL as an 'attachment_url' "\
"(json) to api/images/."
}
end
private
# The image URL in the "untrusted bucket" in Google Cloud Storage
def random_filename
@range ||= "temp/#{SecureRandom.uuid}.jpg"
end
end
end

View File

@ -14,7 +14,7 @@ module Logs
in: CeleryScriptSettingsBag::ALLOWED_CHANNEL_NAMES
# LEGACY SHIM AHEAD!!! ===================================================
#
# We onced stored certain fields in a `meta` column.
# We once stored certain fields in a `meta` column.
# The API has evolved since that time, the requirements are pretty solid
# at this point and we need the ability to perform SQL queries. The `meta`
# field is no longer useful nor is it a clean solution.
@ -27,7 +27,7 @@ module Logs
# should access `log.FOO` instead of `log.meta.FOO` for future
# compatibility.
#
# TODO: delete the `meta` field by 6 JUN 18
# TODO: delete the `meta` field once FBOS < v6.4.0 reach EOL.
string :type, in: Log::TYPES
integer :x
integer :y

View File

@ -17,7 +17,7 @@ module Plants
end
def execute
stub = {pointer_type: "Plant", pointer_id: 0}
stub = { pointer_type: "Plant" }
Plant.create!(inputs.merge(stub))
end
end

View File

@ -16,7 +16,7 @@ module Points
end
def execute
stub = { pointer_type: "GenericPointer", pointer_id: 0 }
stub = { pointer_type: "GenericPointer" }
GenericPointer.create!(inputs.merge(stub))
end
end

View File

@ -1,11 +1,17 @@
module SavedGardens
class Apply < Mutations::Command
DEP_ERROR_REPORT = \
"Unable to remove the following plants from the garden: %s"
required do
model :device, class: Device
model :garden, class: SavedGarden
boolean :destructive # Not yet implemented. RC 4/20/18
end
def validate
plant_safety_check if destructive
end
def execute
clean_out_plants if destructive
convert_templates_to_plants
@ -23,7 +29,6 @@ module SavedGardens
name: template.name,
openfarm_slug: template.openfarm_slug,
plant_stage: "planned",
pointer_id: 0,
radius: template.radius,
x: template.x,
y: template.y,
@ -32,8 +37,22 @@ module SavedGardens
end
def clean_out_plants
Points::Destroy.run!(device: device,
point_ids: device.plants.pluck(:id))
Points::Destroy.run!(device: device, point_ids: device.plants.pluck(:id))
end
def plant_safety_check
add_error :whoops,
:whoops,
plant_error_message if in_use_plants.count > 0
end
def plant_error_message
@plant_error_message ||= \
DEP_ERROR_REPORT % in_use_plants.map(&:fancy_name).join(", ")
end
def in_use_plants
@in_use_plants ||= device.in_use_points.where(pointer_type: "Plant")
end
end
end

View File

@ -20,13 +20,13 @@ module Sequences
def execute
ActiveRecord::Base.transaction do
p = inputs
.merge(migrated_nodes: true)
.without(:body, :args, "body", "args")
p = inputs
.merge(migrated_nodes: true)
.without(:body, :args, "body", "args")
seq = Sequence.create!(p)
x = CeleryScript::FirstPass.run!(sequence: seq,
args: args || {},
body: body || [])
x = CeleryScript::FirstPass.run!(sequence: seq,
args: args || {},
body: body || [])
result = CeleryScript::FetchCelery.run!(sequence: seq)
seq.manually_sync! # We must manually sync this resource.
result

View File

@ -22,7 +22,7 @@ module ToolSlots
end
def execute
stub = {pointer_type: "ToolSlot", pointer_id: 0, device_id: device.id}
stub = {pointer_type: "ToolSlot", device_id: device.id}
ToolSlot.create!(inputs.slice(*FIELDS).merge(stub))
end

View File

@ -8,22 +8,30 @@ module Users
end
def validate
User.try_auth(email, password) { |user| bad_user! unless user }
User.try_auth(email, password) do |user|
bad_user! unless user
end
end
def execute
secret = {
return Base64.encode64(cipher_text)
end
private
def cipher_text
cipher_text ||= KeyGen.current.public_encrypt(secret)
end
def secret
@secret ||= {
email: email,
password: password,
requested_by: device.users.pluck(:email),
id: SecureRandom.hex
}.to_json
ct = KeyGen.current.public_encrypt(secret)
return Base64.encode64(ct)
end
private
def bad_user!
add_error :credentials, :auth, "Bad email or password- can't proceed."
end

View File

@ -2,6 +2,7 @@ module Users
class Update < Mutations::Command
PASSWORD_PROBLEMS = "Password and confirmation(s) must match."
EMAIL_IN_USE = "That email is already registered"
required { model :user, class: User }
optional do
@ -14,49 +15,67 @@ module Users
def validate
confirm_new_password if password
if((email != user.email) && User.where(email: email).any?)
add_error(:email, :in_use, EMAIL_IN_USE)
end
email_is_invalid = attempting_email_change? && user_already_exists?
add_error(:email, :in_use, EMAIL_IN_USE) if email_is_invalid
end
def execute
set_unconfirmed_email if email.present?
excludable = [:user]
excludable.push(:email) unless skip_email_stuff
user.update_attributes!(inputs.except(:user, :email))
if inputs[:password]
SendFactoryResetJob.perform_later(user.device)
delete_all_tokens_except_this_one
end
maybe_perform_password_reset
user.update_attributes!(calculated_update)
user.reload
end
private
def delete_all_tokens_except_this_one
# Lock everyone out except for the person who requested
# the password change.
TokenIssuance
.where(device_id: user.device.id)
.where
.not(jti: (RequestStore[:jwt]||{})[:jti])
.destroy_all
def attempting_email_change?
email != user.email
end
def user_already_exists?
User.where(email: email).any?
end
# Self hosted users will often not have an email server.
# We can update emails immediately in those circumstances.
def skip_email_stuff
@skip_email_stuff ||= !!ENV["NO_EMAILS"] || (email == user.email)
ENV["NO_EMAILS"] || (email == user.email)
end
# Revoke all session tokens except for the person who requested
# the password change.
def delete_all_tokens_except_this_one
TokenIssuance
.where(device_id: user.device.id)
.where
.not(jti: (RequestStore[:jwt] || {})[:jti])
.destroy_all
end
# Send a `factory_reset` RPC over AMQP/MQTT to all connected devices.
# Locks everyone out after a password reset.
def maybe_perform_password_reset
if inputs[:password]
SendFactoryResetJob.perform_later(user.device)
delete_all_tokens_except_this_one
end
end
def set_unconfirmed_email
return if skip_email_stuff
# user.reset_confirmation_token
user.reset_confirmation_token
user.unconfirmed_email = email
user.save!
UserMailer.email_update(user).deliver_later
end
def calculated_update
attributes_excluded_from_update = [:user]
unless skip_email_stuff
set_unconfirmed_email if email.present?
attributes_excluded_from_update.push(:email)
end
inputs.except(*attributes_excluded_from_update)
end
def confirm_new_password
valid_pw = user.valid_password?(password)
has_new_pw = new_password && new_password_confirmation

View File

@ -5,8 +5,6 @@ class ImageSerializer < ActiveModel::Serializer
def attachment_url
url_ = object.attachment.url("x640")
# Force google cloud users to use HTTPS://
x = Api::ImagesController::KEY.present? ?
url_.gsub("http://", "https://") : url_
return x
return ENV["GCS_KEY"].present? ? url_.gsub("http://", "https://") : url_
end
end

View File

@ -0,0 +1,3 @@
We have received your request to export your FarmBot account data.
Please see attached account data.

View File

@ -94,7 +94,7 @@ EXTRA_DOMAINS: staging.farm.bot,whatever.farm.bot
# If you are a software developer and you wish to run integration tests, set the
# ENV below to "true".
# Most users will not want this enabled.
RUN_CAPYBARA: true
RUN_CAPYBARA: "true"
# Set this to "production" in most cases.
# If you need help debugging issues, please delete this line.
RAILS_ENV: "production"

View File

@ -41,7 +41,7 @@ FarmBot::Application.routes.draw do
end
resources :saved_gardens, except: [ :show ] do
post :snapshot, on: :collection
post :snapshot, on: :collection
post :apply, on: :member
patch :apply, on: :member
end
@ -52,7 +52,7 @@ FarmBot::Application.routes.draw do
# resources.
# Might be safe to remove now with the advent of TaggedResource.kind
get "/device/:id" => "devices#show", as: :get_device_redirect
get "/export_data" => "devices#dump", as: :dump_device
post "/export_data" => "devices#dump", as: :dump_device
get "/storage_auth" => "images#storage_auth", as: :storage_auth
patch "/device/:id" => "devices#update", as: :patch_device_redirect
patch "/users/:id" => "users#update", as: :patch_users_redirect
@ -68,11 +68,12 @@ FarmBot::Application.routes.draw do
# =======================================================================
# NON-API (USER FACING) URLS:
# =======================================================================
get "/" => "dashboard#front_page", as: :front_page
get "/app" => "dashboard#main_app", as: :dashboard
get "/app/controls" => "dashboard#main_app", as: :app_landing_page
get "/tos_update" => "dashboard#tos_update", as: :tos_update
post "/csp_reports" => "dashboard#csp_reports", as: :csp_report
get "/" => "dashboard#front_page", as: :front_page
get "/app" => "dashboard#main_app", as: :dashboard
get "/app/controls" => "dashboard#main_app", as: :app_landing_page
get "/tos_update" => "dashboard#tos_update", as: :tos_update
post "/csp_reports" => "dashboard#csp_reports", as: :csp_report
post "/direct_upload" => "dashboard#direct_upload", as: :direct_upload
get "/password_reset/*token" => "dashboard#password_reset", as: :password_reset
get "/verify/:token" => "dashboard#verify", as: :verify_user

View File

@ -0,0 +1,5 @@
class UpdateInUsePointsToVersion2 < ActiveRecord::Migration[5.1]
def change
update_view :in_use_points, version: 2, revert_to_version: 1
end
end

View File

@ -0,0 +1,11 @@
class AprilParameterAdditions < ActiveRecord::Migration[5.1]
NEW_STUFF = { movement_invert_2_endpoints_x: 0,
movement_invert_2_endpoints_y: 0,
movement_invert_2_endpoints_z: 0 }
def change
NEW_STUFF.map do |(name, default)|
add_column :firmware_configs, name, :integer, default: default
end
end
end

View File

@ -0,0 +1,62 @@
class RemoveLegacyPointsTables < ActiveRecord::Migration[5.1]
BIG_WARNING = \
"It appears that your database contains records which still require "\
"migration. Please migrate data before proceeding."
def safe_to_proceed?
any_plants = \
Plant.where(migrated_at: nil).where.not(pointer_id: 0).count > 0
any_tool_slots = \
ToolSlot.where(migrated_at: nil).where.not(pointer_id: 0).count > 0
raise BIG_WARNING if (any_plants || any_tool_slots)
end
def up
safe_to_proceed?
drop_table "legacy_generic_pointers" do |t|
nil
end
drop_table "legacy_plants" do |t|
t.string "openfarm_slug", limit: 280, default: "50", null: false
t.datetime "created_at"
t.datetime "planted_at"
t.string "plant_stage", limit: 10, default: "planned"
t.index ["created_at"], name: "index_legacy_plants_on_created_at"
end
drop_table "legacy_tool_slots" do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "tool_id"
t.integer "pullout_direction", default: 0
t.index ["tool_id"], name: "index_legacy_tool_slots_on_tool_id"
end
remove_column :points, :pointer_id, :integer
end
def down
create_table "legacy_generic_pointers" do |t|
nil
end
create_table "legacy_plants" do |t|
t.string "openfarm_slug", limit: 280, default: "50", null: false
t.datetime "created_at"
t.datetime "planted_at"
t.string "plant_stage", limit: 10, default: "planned"
t.index ["created_at"], name: "index_legacy_plants_on_created_at"
end
create_table "legacy_tool_slots" do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "tool_id"
t.integer "pullout_direction", default: 0
t.index ["tool_id"], name: "index_legacy_tool_slots_on_tool_id"
end
add_column :points, :pointer_id, :integer
end
end

View File

@ -0,0 +1,8 @@
class AddHomeButtonHomingToWebAppConfigs < ActiveRecord::Migration[5.1]
def change
add_column :web_app_configs,
:home_button_homing,
:boolean,
default: false
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180423202520) do
ActiveRecord::Schema.define(version: 20180502050250) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -198,6 +198,9 @@ ActiveRecord::Schema.define(version: 20180423202520) do
t.integer "pin_guard_5_pin_nr", default: 0
t.integer "pin_guard_5_time_out", default: 60
t.boolean "api_migrated", default: false
t.integer "movement_invert_2_endpoints_x", default: 0
t.integer "movement_invert_2_endpoints_y", default: 0
t.integer "movement_invert_2_endpoints_z", default: 0
t.index ["device_id"], name: "index_firmware_configs_on_device_id"
end
@ -222,25 +225,6 @@ ActiveRecord::Schema.define(version: 20180423202520) do
t.index ["device_id"], name: "index_images_on_device_id"
end
create_table "legacy_generic_pointers", id: :serial, force: :cascade do |t|
end
create_table "legacy_plants", id: :serial, force: :cascade do |t|
t.string "openfarm_slug", limit: 280, default: "50", null: false
t.datetime "created_at"
t.datetime "planted_at"
t.string "plant_stage", limit: 10, default: "planned"
t.index ["created_at"], name: "index_legacy_plants_on_created_at"
end
create_table "legacy_tool_slots", id: :serial, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "tool_id"
t.integer "pullout_direction", default: 0
t.index ["tool_id"], name: "index_legacy_tool_slots_on_tool_id"
end
create_table "log_dispatches", force: :cascade do |t|
t.bigint "device_id"
t.bigint "log_id"
@ -317,7 +301,6 @@ ActiveRecord::Schema.define(version: 20180423202520) do
t.datetime "updated_at", null: false
t.string "name", default: "untitled", null: false
t.string "pointer_type", limit: 280, null: false
t.integer "pointer_id", null: false
t.datetime "archived_at"
t.datetime "planted_at"
t.string "openfarm_slug", limit: 280, default: "50", null: false
@ -329,7 +312,6 @@ ActiveRecord::Schema.define(version: 20180423202520) do
t.index ["device_id"], name: "index_points_on_device_id"
t.index ["discarded_at"], name: "index_points_on_discarded_at"
t.index ["meta"], name: "index_points_on_meta", using: :gin
t.index ["pointer_type", "pointer_id"], name: "index_points_on_pointer_type_and_pointer_id"
t.index ["tool_id"], name: "index_points_on_tool_id"
end
@ -490,6 +472,7 @@ ActiveRecord::Schema.define(version: 20180423202520) do
t.string "photo_filter_end"
t.boolean "discard_unsaved", default: false
t.boolean "xy_swap", default: false
t.boolean "home_button_homing", default: false
t.index ["device_id"], name: "index_web_app_configs_on_device_id"
end
@ -505,7 +488,6 @@ ActiveRecord::Schema.define(version: 20180423202520) do
add_foreign_key "device_configs", "devices"
add_foreign_key "edge_nodes", "sequences"
add_foreign_key "farmware_installations", "devices"
add_foreign_key "legacy_tool_slots", "tools"
add_foreign_key "log_dispatches", "devices"
add_foreign_key "log_dispatches", "logs"
add_foreign_key "peripherals", "devices"
@ -518,22 +500,6 @@ ActiveRecord::Schema.define(version: 20180423202520) do
add_foreign_key "sensors", "devices"
add_foreign_key "token_issuances", "devices"
create_view "in_use_points", sql_definition: <<-SQL
SELECT points.x,
points.y,
points.z,
(edge_nodes.value)::integer AS point_id,
points.pointer_type,
points.name AS pointer_name,
sequences.id AS sequence_id,
sequences.name AS sequence_name,
edge_nodes.id AS edge_node_id
FROM ((edge_nodes
JOIN sequences ON ((edge_nodes.sequence_id = sequences.id)))
JOIN points ON (((edge_nodes.value)::integer = points.id)))
WHERE ((edge_nodes.kind)::text = 'pointer_id'::text);
SQL
create_view "in_use_tools", sql_definition: <<-SQL
SELECT tools.id AS tool_id,
tools.name AS tool_name,
@ -560,4 +526,21 @@ ActiveRecord::Schema.define(version: 20180423202520) do
FROM sequences;
SQL
create_view "in_use_points", sql_definition: <<-SQL
SELECT points.x,
points.y,
points.z,
sequences.id AS sequence_id,
edge_nodes.id AS edge_node_id,
points.device_id,
(edge_nodes.value)::integer AS point_id,
points.pointer_type,
points.name AS pointer_name,
sequences.name AS sequence_name
FROM ((edge_nodes
JOIN sequences ON ((edge_nodes.sequence_id = sequences.id)))
JOIN points ON (((edge_nodes.value)::integer = points.id)))
WHERE ((edge_nodes.kind)::text = 'pointer_id'::text);
SQL
end

View File

@ -60,7 +60,6 @@ unless Rails.env == "production"
y: rand(40...470),
radius: rand(10...50),
name: Faker::StarWars.call_sign,
pointer_id: 0,
openfarm_slug: ["tomato", "carrot", "radish", "garlic"].sample)
end
@ -72,7 +71,6 @@ unless Rails.env == "production"
y: rand(40...470) + rand(40...470),
z: 5,
radius: (rand(1...150) + rand(1...150)) / 20,
pointer_id: 0,
meta: {
created_by: "plant-detection",
color: (Sequence::COLORS + [nil]).sample

View File

@ -0,0 +1,15 @@
SELECT
points.x as x,
points.y as y,
points.z as z,
sequences.id as sequence_id,
edge_nodes.id as edge_node_id,
points.device_id as device_id,
(edge_nodes.value)::int as point_id,
points.pointer_type as pointer_type,
points.name as pointer_name,
sequences.name as sequence_name
FROM "edge_nodes"
INNER JOIN "sequences" ON edge_nodes.sequence_id=sequences.id
INNER JOIN "points" ON (edge_nodes.value)::int=points.id
WHERE "edge_nodes"."kind" = 'pointer_id';

View File

@ -25,7 +25,7 @@ def ping(interval = 0)
$count += 1
puts "Log ##{$count}"
$log[:message] = "Hey! #{$count}"
Transport.amqp_send($log.to_json, $device_id, "logs")
Transport.current.amqp_send($log.to_json, $device_id, "logs")
end
loop do

View File

@ -3,11 +3,12 @@ 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)
end
rescue => Bunny::TCPConnectionFailedForAllHosts
rescue StandardError => e
puts "MQTT Broker is unreachable. Waiting 5 seconds..."
sleep 5
retry

View File

@ -2,7 +2,7 @@ class LogService
# Determines if the log should be discarded (Eg: "fun"/"debug" logs do not
# go in the DB)
def self.save?(log_as_ruby_hash)
# TODO: Once we gt rid of legacy `log.meta` calls,
# TODO: Once we get rid of legacy `log.meta` calls (FBOS < 6.4.0 EOL),
# this method can be simplified.
is_a_hash = log_as_ruby_hash.is_a?(Hash)
hash = is_a_hash ? log_as_ruby_hash : {}

View File

@ -48,7 +48,7 @@ class Typescript
klass.columns.map do |col|
t = col.sql_type_metadata.sql_type
col_type = TYPE_MAPPING[t] or raise "NO! #{t.inspect} is not in TYPE_MAPPING"
Pair[col.name, col_type]
Pair.new(col.name, col_type)
end
end
@ -57,8 +57,8 @@ class Typescript
.group_by { |x| x.tail }
.to_a
.map do |arr|
Pair["#{arr.first.camelize}ConfigKey",
arr.last.map{|x| x.head.inspect }.join("\n |") + ";\n"]
Pair.new "#{arr.first.camelize}ConfigKey",
arr.last.map{|x| x.head.inspect }.join("\n |") + ";\n"
end
end
end

View File

@ -53,7 +53,7 @@
"enzyme": "^3.1.0",
"enzyme-adapter-react-16": "^1.1.0",
"extract-text-webpack-plugin": "3.0.2",
"farmbot": "5.4.0",
"farmbot": "6.0.0-rc2",
"farmbot-toastr": "^1.0.3",
"fastclick": "^1.0.6",
"file-loader": "1.1.7",

View File

@ -1,29 +1,35 @@
require 'spec_helper'
describe Api::DevicesController do
include Devise::Test::ControllerHelpers
let(:user) { FactoryBot.create(:user) }
EXPECTED = [:device,
:images,
:regimens,
:peripherals,
:farm_events,
# :tools,
# :points,
:users,
:webcam_feeds]
describe '#dump' do
it 'creates a backup of your account' do
# NOTE: As of 11 December 17, the dump endpoint is only for dev purposes.
# Not going to spend a bunch of time writing unit tests for this
# endpoint- just basic syntax checking.
it 'queues the creation of an account backup' do
sign_in user
get :dump, params: {}, session: { format: :json }
empty_mail_bag
run_jobs_now do
post :dump, params: {}, session: { format: :json }
end
expect(response.status).to eq(202)
mail = ActionMailer::Base.deliveries.last
expect(mail).to be_kind_of(Mail::Message)
expect(mail.to).to include(user.email)
expect(mail.subject).to eq(DataDumpMailer::SUBJECT)
expect(mail.attachments.count).to eq(1)
expect(mail.attachments.first.filename)
.to eq(DataDumpMailer::EXPORT_FILENAME)
end
it 'stores to disk when no email server is available' do
b4 = Api::DevicesController.send_emails
Api::DevicesController.send_emails = false
sign_in user
post :dump, params: {}, session: { format: :json }
expect(response.status).to eq(200)
actual = json.keys
EXPECTED.map { |key| expect(actual).to include(key) }
# Just a spot check. Handle in depth usage at the mutation spec level. -RC
expect(json[:tools]).to be_kind_of(Array)
Api::DevicesController.send_emails = b4
end
end
end

View File

@ -3,7 +3,7 @@ require 'spec_helper'
describe Api::ImagesController do
include Devise::Test::ControllerHelpers
let(:user) { FactoryBot.create(:user) }
it "Creates a polict object" do
it "Creates a policy object" do
sign_in user
get :storage_auth
@ -16,6 +16,21 @@ describe Api::ImagesController do
.to include("POST the resulting URL as an 'attachment_url'")
end
it "Creates a (stub) policy object" do
sign_in user
b4 = Api::ImagesController.store_locally
Api::ImagesController.store_locally = false
get :storage_auth
Api::ImagesController.store_locally = b4
expect(response.status).to eq(200)
expect(json).to be_kind_of(Hash)
expect(json[:verb]).to eq("POST")
expect(json[:url]).to include($API_URL)
[ :policy, :signature, :GoogleAccessId ]
.map { |key| expect(json.dig(:form_data, key)).to eq("N/A") }
expect(json[:form_data].keys.sort).to include(:signature)
end
describe '#index' do
it 'shows only the max images allowed' do
sign_in user

View File

@ -100,26 +100,6 @@ describe Api::LogsController do
expect(user.device.logs.count).to eq(0)
end
it "(PENDING) delivers emails for logs marked as `email`" do
pending "Something is not right with the queue adapter in test ENV 🤔"
sign_in user
empty_mail_bag
before_count = LogDispatch.count
body = { meta: { x: 1, y: 2, z: 3, type: "info" },
channels: ["email"],
message: "Heyoooo" }.to_json
run_jobs_now do
post :create, body: body, params: {format: :json}
after_count = LogDispatch.count
expect(response.status).to eq(200)
expect(last_email).to be
expect(last_email.body.to_s).to include("Heyoooo")
expect(last_email.to).to include(user.email)
expect(before_count).to be < after_count
expect(LogDispatch.where(sent_at: nil).count).to eq(0)
end
end
it "delivers emails for logs marked as `email`" do
LogDispatch.destroy_all
log = logs.first

View File

@ -20,7 +20,6 @@ describe Api::PointsController do
radius: 1,
openfarm_slug: "lettuce",
pointer_type: "Plant",
pointer_id: 0,
device: device)
}
let(:tool) {Tool.create!(device: user.device)}
@ -31,7 +30,6 @@ describe Api::PointsController do
radius: 50,
name: "Whatever",
pointer_type: "ToolSlot",
pointer_id: 0,
device: user.device,
tool_id: tool.id)
end

View File

@ -24,7 +24,6 @@ describe Api::PointsController do
device: user.device,
openfarm_slug: "cabbage",
pointer_type: "Plant",
pointer_id: 0,
discarded_at: Time.now)
Plant.create!(x: 5,
@ -35,7 +34,6 @@ describe Api::PointsController do
device: user.device,
openfarm_slug: "cabbage",
pointer_type: "Plant",
pointer_id: 0,
discarded_at: nil)
SmarfDoc.note("If you want to see previously deleted points, " +
"add `?filter=old` to the end of the URL.")
@ -56,7 +54,6 @@ describe Api::PointsController do
device: user.device,
openfarm_slug: "cabbage",
pointer_type: "Plant",
pointer_id: 0,
discarded_at: Time.now)
Plant.create!(x: 5,
@ -67,7 +64,6 @@ describe Api::PointsController do
device: user.device,
openfarm_slug: "cabbage",
pointer_type: "Plant",
pointer_id: 0,
discarded_at: nil)
SmarfDoc.note("If you want to see previously deleted points alongside" \
" your active points, add `?filter=all` to the end of " \
@ -98,8 +94,7 @@ describe Api::PointsController do
name: "Cabbage #{num}",
device: user.device,
openfarm_slug: "cabbage",
pointer_type: "Plant",
pointer_id: 0)
pointer_type: "Plant")
end
sign_in user
get :index
@ -115,8 +110,7 @@ describe Api::PointsController do
radius: 50,
name: "My TS",
device: user.device,
pointer_type: "ToolSlot",
pointer_id: 0)
pointer_type: "ToolSlot")
get :index
expect(json.first[:id]).to eq(ts.id)
expect(json.first[:name]).to eq(ts.name)

View File

@ -13,8 +13,7 @@ describe Api::PointsController do
z: 0,
radius: 0,
device: user.device,
pointer_type: "ToolSlot",
pointer_id: 0)
pointer_type: "ToolSlot")
sign_in user
payload = { id: tool_slot.id }
get :show, params: payload

View File

@ -10,7 +10,6 @@ describe Api::PointsController do
y: 0,
z: 0,
radius: 1,
pointer_id: 0,
pointer_type: "ToolSlot",
device_id: user.device.id) }
@ -38,8 +37,7 @@ describe Api::PointsController do
radius: 1,
openfarm_slug: "lettuce",
device: user.device,
pointer_type: "Plant",
pointer_id: 0)
pointer_type: "Plant")
sign_in user
p = { id: point.id,
x: 23,

View File

@ -86,31 +86,36 @@ describe Api::SavedGardensController do
expect(user.device.plants.count).to be > old_plant_count
end
it "prevents destructive application when plants in use."# do
# SavedGarden.destroy_all
# Plant.destroy_all
# PlantTemplate.destroy_all
# sign_in user
# saved_garden = FactoryBot.create(:saved_garden, device: user.device)
# FactoryBot.create_list(:plant_template, 3, device: user.device,
# saved_garden: saved_garden)
# FakeSequence.create(device: user.device,
# body: [{ kind: "move_absolute",
# args: {
# location: {
# kind: "point",
# args: { pointer_type: "Plant", pointer_id: plant.id }
# },
# speed: 100,
# offset: { kind: "", args: { } }
# }
# }])
it "prevents destructive application when plants in use." do
SavedGarden.destroy_all
Plant.destroy_all
PlantTemplate.destroy_all
sign_in user
saved_garden = FactoryBot.create(:saved_garden, device: user.device)
FactoryBot.create_list(:plant_template, 3, device: user.device,
saved_garden: saved_garden)
plant = FactoryBot.create(:plant, device: user.device)
FakeSequence.create(device: user.device,
body: [{ kind: "move_absolute",
args: {
location: {
kind: "point",
args: { pointer_type: "Plant", pointer_id: plant.id }
},
speed: 100,
offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }
}
}])
# old_plant_count = user.device.plants.count
# patch :apply, params: {id: saved_garden.id }
# expect(response.status).to be(200)
# expect(user.device.plants.count).to be > old_plant_count
# end
old_plant_count = user.device.plants.count
post :apply, params: {id: saved_garden.id }
expect(response.status).to be(422)
expect(user.device.plants.count).to eq(old_plant_count)
expect(json[:whoops])
.to include("Unable to remove the following plants from the garden")
expect(json[:whoops])
.to include("plant at (#{plant.x}, #{plant.y}, #{plant.z})")
end
it "performs 'destructive' garden application" do
SavedGarden.destroy_all

View File

@ -49,9 +49,13 @@ describe Api::UsersController do
patch :update, params: input
expect(response.status).to eq(200)
expect(json[:name]).to eq("Ricky McRickerson")
# Updates to user email require confirmation.
expect(json[:email]).not_to eq(input[:email])
expect(json[:email]).to eq(user.email)
unless User::SKIP_EMAIL_VALIDATION
# Updates to user email require confirmation.
expect(json[:email]).not_to eq(input[:email])
expect(json[:email]).to eq(user.email)
else
expect(json[:email]).to eq(input[:email])
end
end
it 'updates password' do

View File

@ -50,5 +50,16 @@ describe DashboardController do
expect(user.reload.unconfirmed_email).to be nil
expect(user.email).to eq email
end
it 'handles self hosted image uploads' do
name = "wow.jpg"
params = {key: "whatever/" + name,
file: StringIO.new(File.open("./spec/fixture.jpg").read)}
post :direct_upload, params: params
file = File.join("public", "direct_upload", "temp", name)
expect(File.file?(file)).to be(true)
expect(response.status).to be(200)
File.delete(file)
end
end
end

View File

@ -0,0 +1,4 @@
FactoryBot.define do
factory :fbos_config do
end
end

View File

@ -0,0 +1,4 @@
FactoryBot.define do
factory :firmware_config do
end
end

View File

@ -7,6 +7,5 @@ FactoryBot.define do
meta ({})
device
pointer_type GenericPointer.name
pointer_id(0)
end
end

View File

@ -8,6 +8,5 @@ FactoryBot.define do
device
openfarm_slug "lettuce"
pointer_type "Plant"
pointer_id 0
end
end

View File

@ -7,6 +7,5 @@ FactoryBot.define do
device
tool
pointer_type("ToolSlot")
pointer_id(0)
end
end

View File

@ -0,0 +1,4 @@
FactoryBot.define do
factory :web_app_config do
end
end

View File

@ -1,13 +0,0 @@
require 'spec_helper'
RSpec.describe SendFactoryResetJob, type: :job do
let(:device) { FactoryBot.create(:device) }
it 'sends a factory_reset RPC' do
dbl = double("fake transport layer")
payl = SendFactoryResetJob.rpc_payload(device)
expect(dbl)
.to receive(:amqp_send).with(payl.to_json, device.id, "from_clients")
SendFactoryResetJob.new.perform(device, dbl)
end
end

View File

@ -26,11 +26,12 @@ describe LogService do
end
it "calls .subscribe() on Transport." do
fakee = FakeLogChan.new
allow(Transport).to receive(:log_channel) { fakee }
expect(fakee.subcribe_calls).to eq(0)
Transport.current.clear!
load "lib/log_service.rb"
expect(fakee.subcribe_calls).to eq(1)
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 "creates new messages in the DB when called" do

View File

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

View File

@ -0,0 +1,4 @@
# Preview all emails at http://localhost:3000/rails/mailers/data_dump
class DataDumpPreview < ActionMailer::Preview
end

View File

@ -26,10 +26,14 @@ describe Device do
expect([-5, -6, -7]).to include device.tz_offset_hrs # Remember DST!
end
it 'uses `tell` to send device messages' do
dbl = double("fake transport layer")
expect(dbl).to receive(:amqp_send)
result = device.tell("Hello!", dbl)
expect(result.message).to eq("Hello!")
it "sends specific users toast messages" do
Transport.current.clear!
hello = "Hello!"
log = device.tell(hello)
json, info = Transport.current.connection.calls[:publish].last
json = JSON.parse(json)
expect(info[:routing_key]).to eq("bot.device_#{device.id}.logs")
expect(log.message).to eq(hello)
expect(json["message"]).to eq(hello)
end
end

View File

@ -1,44 +0,0 @@
require "spec_helper"
describe "Legacy models" do
let(:device) { FactoryBot.create(:device) }
it "does not broadcast the changes" do
[LegacyToolSlot, LegacyGenericPointer, LegacyPlant]
.map(&:new)
.map(&:broadcast?)
.map{ |x| expect(x).to be(false) }
end
it "migrates tool_slots" do
tool = Tool.create!(name: "test case", device: device)
ts = LegacyToolSlot.create!(tool_id: tool.id, pullout_direction: 1)
pointer = ToolSlot.create!(pointer_type: "ToolSlot",
pointer_id: ts.id,
device: device,
x: 0,
y: 0,
z: 0)
expect(pointer.tool_id).to eq(nil)
expect(pointer.pullout_direction).to eq(0)
pointer.do_migrate
expect(pointer.tool_id).to eq(tool.id)
expect(pointer.pullout_direction).to eq(1)
end
it "migrates plants" do
plant = LegacyPlant.create!(openfarm_slug: "churros",
plant_stage: "planted")
pointer = Plant.create!(pointer_type: "Plant",
pointer_id: plant.id,
device: device,
x: 0,
y: 0,
z: 0)
expect(pointer.openfarm_slug).to eq("50")
expect(pointer.plant_stage).to eq("planned")
pointer.do_migrate
expect(pointer.openfarm_slug).to eq("churros")
expect(pointer.plant_stage).to eq("planted")
end
end

View File

@ -1,8 +1,2 @@
describe Point do
it "detects infinity" do
expect(Point.new(x: (1.0/0.0)).is_infinite?).to be true
expect(Point.new(y: (1.0/0.0)).is_infinite?).to be true
expect(Point.new(z: (1.0/0.0)).is_infinite?).to be true
expect(Point.new(x: 1, y: 1000, z: -2).is_infinite?).to be false
end
end

View File

@ -0,0 +1,32 @@
require "spec_helper"
describe Devices::Dump do
# Resources that I can't easily instantiate via FactoryBot
SPECIAL = [:points, :sequences, :device]
ADDITIONAL = [:plants, :tool_slots, :generic_pointers]
MODEL_NAMES = Devices::Dump::RESOURCES.without(*SPECIAL).concat(ADDITIONAL)
it "serializes _all_ the data" do
device = FactoryBot.create(:device)
resources = MODEL_NAMES
.map { |x| x.to_s.singularize.to_sym }
.map { |x| FactoryBot.create_list(x, 4, device: device) }
tools = FactoryBot.create_list(:tool, 3, device: device)
device
.points
.where(pointer_type: "ToolSlot")
.last
.update_attributes(tool_id: tools.last.id)
results = Devices::Dump.run!(device: device)
MODEL_NAMES
.concat(SPECIAL)
.without(:device, :plants, :tool_slots, :generic_pointers)
.map do |name|
expect(results[name]).to be
expect(results[name]).to eq(device.send(name).map(&:body_as_json))
end
# Tests for a regression noted on 1 may 18:
tool = results[:tools].find { |x| x[:id] == tools.last.id }
expect(tool).to be
expect(tool[:status]).to eq("active")
end
end

View File

@ -26,9 +26,17 @@ describe Users::Update do
it 'changes email addresses' do
u = FactoryBot.create(:user)
original_token = u.confirmation_token
expect(u.unconfirmed_email?).to be false
Users::Update.run!(user: u, email: "example@mailinator.com")
expect(u.unconfirmed_email?).to be true
expect(u.unconfirmed_email).to eq("example@mailinator.com")
if User::SKIP_EMAIL_VALIDATION
expect(u.unconfirmed_email?).to be false
expect(u.email).to eq("example@mailinator.com")
else
expect(u.unconfirmed_email?).to be true
expect(u.unconfirmed_email).to eq("example@mailinator.com")
expect(u.confirmation_token).not_to eq(original_token)
end
end
end

View File

@ -17,9 +17,47 @@ require "pry"
ENV["RAILS_ENV"] ||= "test"
require File.expand_path("../../config/environment", __FILE__)
# This is a stub for BunnyRB because we don't want the test suite to connect to
# AMQP for real.
class FakeTransport < Transport
# Theses are the "real" I/O inducing methods that must be Stubbed out.
MOCKED_METHODS = \
[ :bind, :publish, :queue, :subscribe, :create_channel, :topic ]
# When you call an AMQP I/O method, instead of doing real I/O, it will deposit
# the call into the @calls dictionary for observation.
attr_reader :calls
MOCKED_METHODS.map do |name|
# Eval is Evil, but this is pretty quick for testing.
eval """
def #{name}(*x)
key = #{name.inspect}
(@calls[key] ||= []).push(x)
@calls[key] = @calls[key].last(10)
self
end
"""
end
def initialize(*)
self.clear!
end
def start
self
end
def clear!
@calls = {}
end
end
Transport.default_amqp_adapter = FakeTransport
Transport.current = Transport.default_amqp_adapter.new
require "rspec/rails"
require_relative "./stuff"
require_relative "./topic_stub"
require_relative "./fake_sequence"
Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
@ -41,8 +79,8 @@ RSpec.configure do |config|
require "capybara/rspec"
require "selenium/webdriver"
# Be sure to run `RAILS_ENV=test rails api:start` and `rails mqtt:start`!
Capybara.run_server = false
Capybara.app_host = "http://localhost:3000"
Capybara.run_server = false
Capybara.app_host = "http://localhost:3000"
Capybara.server_host = "localhost"
Capybara.server_port = "3000"
end

View File

@ -1,21 +0,0 @@
# Fake RabbitMQ adapter for when we test things.
class TopicStub
def self.publish(msg, opts)
end
end
class ChannelStub
def self.topic(name, opts)
return TopicStub
end
end
class MQTTStub
def self.create_channel
return ChannelStub
end
end
def Transport.connection
return MQTTStub
end

View File

@ -212,6 +212,7 @@ export function fakeWebAppConfig(): TaggedWebAppConfig {
photo_filter_end: "2018-01-22T15:32:41.970Z",
discard_unsaved: false,
xy_swap: false,
home_button_homing: false,
});
}

View File

@ -0,0 +1,42 @@
const mock = {
response: {
data: (undefined as any) // Mutable
}
};
jest.mock("farmbot-toastr", () => ({ success: jest.fn() }));
jest.mock("axios",
() => ({ default: { post: jest.fn(() => Promise.resolve(mock.response)) } }));
import { API } from "../../api";
import { Content } from "../../constants";
import { requestAccountExport } from "../request_account_export";
import { success } from "farmbot-toastr";
import axios from "axios";
API.setBaseUrl("http://www.foo.bar");
describe("requestAccountExport", () => {
it("pops toast on completion (when API has email support)", async () => {
await requestAccountExport();
expect(axios.post).toHaveBeenCalledWith(API.current.exportDataPath);
expect(success).toHaveBeenCalledWith(Content.EXPORT_SENT);
});
it("downloads the data synchronously (when API has no email support)", async () => {
mock.response.data = {};
window.URL = window.URL || ({} as any);
window.URL.createObjectURL = jest.fn();
window.URL.revokeObjectURL = jest.fn();
const a = await requestAccountExport();
if (a) {
expect(a).toBeInstanceOf(HTMLElement);
expect(a.tagName).toBe("A");
expect(window.URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(window.URL.revokeObjectURL).toHaveBeenCalled();
mock.response.data = undefined;
} else {
fail();
}
});
});

View File

@ -0,0 +1,29 @@
import * as React from "react";
import { Widget, WidgetHeader, WidgetBody, Row, Col } from "../../ui";
import { Content } from "../../constants";
import { t } from "i18next";
export function ExportAccountPanel(props: { onClick: () => void }) {
return <Widget>
<WidgetHeader title="Export Account Data" />
<WidgetBody>
<div>
{t(Content.EXPORT_DATA_DESC)}
</div>
<form>
<Row>
<Col xs={8}>
<label>
{t("Send Account Export File (Email)")}
</label>
</Col>
<Col xs={4}>
<button onClick={props.onClick} className="green fb-button" type="button" >
{t("Export")}
</button>
</Col>
</Row>
</form>
</WidgetBody>
</Widget>;
}

View File

@ -11,6 +11,8 @@ import { updateNO } from "../resources/actions";
import { deleteUser } from "./actions";
import { success } from "farmbot-toastr/dist";
import { LabsFeatures } from "./labs/labs_features";
import { ExportAccountPanel } from "./components/export_account_panel";
import { requestAccountExport } from "./request_account_export";
const KEYS: (keyof User)[] = ["id", "name", "email", "created_at", "updated_at"];
@ -32,7 +34,7 @@ export class Account extends React.Component<Props, State> {
* changed, but not which field).
*
* This is a quick fix until we can dedicate resources to implement
* reversable edits to TaggedResource. I don't want to do anything weird like
* reversible edits to TaggedResource. I don't want to do anything weird like
* store `TaggedResource`s in `this.state` (risk of storing invalid UUIDs).
*
* TODO: Implement attribute level dirty tracking
@ -62,6 +64,9 @@ export class Account extends React.Component<Props, State> {
.then(this.doSave, updateNO);
render() {
const deleteAcct =
(password: string) => this.props.dispatch(deleteUser({ password }));
return <Page className="account">
<Col xs={12} sm={6} smOffset={3}>
<Row>
@ -77,10 +82,10 @@ export class Account extends React.Component<Props, State> {
<LabsFeatures />
</Row>
<Row>
<DeleteAccount
onClick={(password) => this
.props
.dispatch(deleteUser({ password }))} />
<DeleteAccount onClick={deleteAcct} />
</Row>
<Row>
<ExportAccountPanel onClick={requestAccountExport} />
</Row>
</Col>
</Page>;

View File

@ -0,0 +1,32 @@
import { API } from "../api";
import { Content } from "../constants";
import { success } from "farmbot-toastr";
import { t } from "i18next";
import axios, { AxiosResponse } from "axios";
// Thanks, @KOL - https://stackoverflow.com/a/19328891/1064917
function handleNow(data: object) {
// When email is not available on the API (self hosted).
// Will synchronously load backup over the wire (slow)
const a = document.createElement("a");
document.body.appendChild(a);
a.style.display = "none";
const json = JSON.stringify(data),
blob = new Blob([json], { type: "octet/stream" }),
url = window.URL.createObjectURL(blob);
a.href = url;
a.download = "farmbot_export.json";
a.click();
window.URL.revokeObjectURL(url);
return a;
}
const ok = (resp: AxiosResponse<{} | undefined>) => {
const { data } = resp;
return data ? handleNow(data) : success(t(Content.EXPORT_SENT));
};
export const requestAccountExport =
() => axios
.post(API.current.exportDataPath)
.then(ok);

View File

@ -92,6 +92,10 @@ export class API {
get devicePath() { return `${this.baseUrl}/api/device/`; }
/** /api/users/ */
get usersPath() { return `${this.baseUrl}/api/users/`; }
/** /api/users/control_certificate */
get transferCertPath() {
return `${this.baseUrl}/api/users/control_certificate`;
}
/** /api/users/resend_verification */
get userResendConfirmationPath() {
return this.usersPath + "/resend_verification";
@ -138,6 +142,7 @@ export class API {
/** /api/saved_gardens/:id/apply */
applyGardenPath =
(gardenId: number) => `${this.savedGardensPath}/${gardenId}/apply`;
get exportDataPath() { return `${this.baseUrl}/api/export_data`; }
/** /api/plant_templates/:id */
get plantTemplatePath() { return `${this.baseUrl}/api/plant_templates`; }
/** /api/farmware_installations/:id */

View File

@ -100,6 +100,9 @@ export interface FirmwareConfig {
pin_guard_5_pin_nr: number;
pin_guard_5_time_out: number;
api_migrated: boolean;
movement_invert_2_endpoints_x: number;
movement_invert_2_endpoints_y: number;
movement_invert_2_endpoints_z: number;
}
export type NumberConfigKey = "id"
@ -192,7 +195,10 @@ export type NumberConfigKey = "id"
|"pin_guard_4_time_out"
|"pin_guard_5_active_state"
|"pin_guard_5_pin_nr"
|"pin_guard_5_time_out";
|"pin_guard_5_time_out"
|"movement_invert_2_endpoints_x"
|"movement_invert_2_endpoints_y"
|"movement_invert_2_endpoints_z";
export type StringConfigKey = "created_at"
|"updated_at";

View File

@ -45,6 +45,7 @@ export interface WebAppConfig {
photo_filter_end: string;
discard_unsaved: boolean;
xy_swap: boolean;
home_button_homing: boolean;
}
export type NumberConfigKey = "id"
@ -87,4 +88,5 @@ export type BooleanConfigKey = "confirm_step_deletion"
|"enable_browser_speak"
|"show_images"
|"discard_unsaved"
|"xy_swap";
|"xy_swap"
|"home_button_homing";

View File

@ -2,7 +2,6 @@ import {
determineStrategy,
SyncStrat,
maybeNegateStatus,
maybeNegateConsistency
} from "../maybe_negate_status";
describe("determineStrategy()", () => {
@ -16,11 +15,6 @@ describe("determineStrategy()", () => {
.toBe(SyncStrat.MANUAL);
});
it("finds detects LEGACY users", () => {
expect(determineStrategy({ fbosVersion: "2.0.0", autoSync: true }))
.toBe(SyncStrat.LEGACY);
});
it("finds detects OFFLINE users", () => {
expect(determineStrategy({ autoSync: false })).toBe(SyncStrat.OFFLINE);
});
@ -91,47 +85,3 @@ describe("maybeNegateStatus()", () => {
expect(result).toEqual("sync_now");
});
});
describe("maybeNegateConsistency()", () => {
it("sets consistency to `true` when bot is `syncing` (legacy mode)", () => {
const result = maybeNegateConsistency({
autoSync: false,
fbosVersion: "0.0.1",
syncStatus: "syncing",
consistent: false
});
expect(result).toBe(true);
});
it("returns original value when Legacy && !syncing", () => {
expect(maybeNegateConsistency({
autoSync: false,
fbosVersion: "0.0.1",
syncStatus: "unknown",
consistent: false
})).toBe(false);
expect(maybeNegateConsistency({
autoSync: false,
fbosVersion: "0.0.1",
syncStatus: "unknown",
consistent: true
})).toBe(true);
});
it("Skips this step for non-legacy versions", () => {
expect(maybeNegateConsistency({
autoSync: false,
fbosVersion: "6.0.0",
syncStatus: "unknown",
consistent: true
})).toBe(true);
expect(maybeNegateConsistency({
autoSync: false,
fbosVersion: "6.0.0",
syncStatus: "unknown",
consistent: false
})).toBe(false);
});
});

View File

@ -25,22 +25,25 @@ import {
PING,
ACTIVE_THRESHOLD
} from "../ping_mqtt";
import { Farmbot, Dictionary } from "farmbot";
import { Farmbot } from "farmbot";
import { dispatchNetworkDown, dispatchNetworkUp } from "../index";
import { FarmBotInternalConfig } from "farmbot/dist/config";
const TOO_LATE_TIME_DIFF = ACTIVE_THRESHOLD + 1;
const ACCEPTABLE_TIME_DIFF = ACTIVE_THRESHOLD - 1;
let state: Dictionary<string | number | boolean> = {
let state: Partial<FarmBotInternalConfig> = {
[LAST_IN]: 123, [LAST_OUT]: 456
};
function fakeBot(): Farmbot {
const fb: Partial<Farmbot> = {
setState: jest.fn(),
setConfig: jest.fn(),
publish: jest.fn(),
on: jest.fn(),
getState() { return state; }
getConfig: jest.fn((key: keyof FarmBotInternalConfig) => {
return (state as FarmBotInternalConfig)[key];
})
};
return fb as Farmbot;
@ -63,12 +66,10 @@ describe("ping util", () => {
it("sets the LAST_PING_(IN|OUT) in bot state", () => {
const bot = fakeBot();
writePing(bot, "in");
expect(bot.setState)
.toHaveBeenCalledWith(LAST_IN, expect.any(Number));
expect(bot.setConfig).toHaveBeenCalledWith(LAST_IN, expect.any(Number));
jest.clearAllMocks();
writePing(bot, "out");
expect(bot.setState)
.toHaveBeenCalledWith(LAST_OUT, expect.any(Number));
expect(bot.setConfig).toHaveBeenCalledWith(LAST_OUT, expect.any(Number));
});
it("reads LAST_PING_(IN|OUT)", () => {

View File

@ -12,7 +12,8 @@ import {
EXPECTED_MAJOR,
EXPECTED_MINOR,
commandOK,
badVersion
badVersion,
commandErr
} from "../devices/actions";
import { init } from "../api/crud";
import { AuthState } from "../auth/interfaces";
@ -90,7 +91,7 @@ export function readStatus() {
const noun = "'Read Status' command";
return getDevice()
.readStatus()
.then(() => { commandOK(noun); }, () => { });
.then(() => { commandOK(noun); }, commandErr(noun));
}
export const onOffline = () => {
@ -101,7 +102,7 @@ export const onOffline = () => {
export const changeLastClientConnected = (bot: Farmbot) => () => {
bot.setUserEnv({
"LAST_CLIENT_CONNECTED": JSON.stringify(new Date())
}).catch(() => { });
}).catch(() => { }); // This is internal stuff, don't alert user.
};
const onStatus = (dispatch: Function, getState: GetState) =>
(throttle(function (msg: BotStateTree) {
@ -136,17 +137,19 @@ export const onReconnect =
() => warning(t("Attempting to reconnect to the message broker"), t("Offline"));
const attachEventListeners =
(bot: Farmbot, dispatch: Function, getState: GetState) => {
startPinging(bot);
readStatus().then(changeLastClientConnected(bot), noop);
bot.on("online", onOnline);
bot.on("online", () => bot.readStatus().then(noop, noop));
bot.on("offline", onOffline);
bot.on("sent", onSent(bot.client));
bot.on("logs", onLogs(dispatch, getState));
bot.on("status", onStatus(dispatch, getState));
bot.on("malformed", onMalformed);
bot.client.on("message", autoSync(dispatch, getState));
bot.client.on("reconnect", onReconnect);
if (bot.client) {
startPinging(bot);
readStatus().then(changeLastClientConnected(bot), noop);
bot.on("online", onOnline);
bot.on("online", () => bot.readStatus().then(noop, noop));
bot.on("offline", onOffline);
bot.on("sent", onSent(bot.client));
bot.on("logs", onLogs(dispatch, getState));
bot.on("status", onStatus(dispatch, getState));
bot.on("malformed", onMalformed);
bot.client.on("message", autoSync(dispatch, getState));
bot.client.on("reconnect", onReconnect);
}
};
/** Connect to MQTT and attach all relevant event handlers. */

View File

@ -1,4 +1,3 @@
import { semverCompare, SemverResult } from "../util";
import { SyncStatus } from "farmbot";
/** There are a bunch of ways we need to handle data consistency management
@ -8,15 +7,10 @@ export enum SyncStrat {
AUTO,
/** Auto sync is not enabled by user*/
MANUAL,
/** Device does not support auto_sync in any way. */
LEGACY,
/** Not enough info to say. */
OFFLINE
}
/** Highest version lacking auto sync. Remove in April 2018 -RC */
const TOO_OLD = "5.0.6";
/** "Hints" for figuring out which of the 4 strategies is appropriate. */
interface StratHints {
/** Not always available if device is offline. */
@ -31,12 +25,7 @@ export function determineStrategy(x: StratHints): SyncStrat {
return SyncStrat.OFFLINE;
}
/** Second pass: Is it an old version? */
if (semverCompare(TOO_OLD, fbosVersion) !== SemverResult.RIGHT_IS_GREATER) {
return SyncStrat.LEGACY;
}
/** Third pass: Is auto_sync enabled? */
/** Second pass: Is auto_sync enabled? */
const strat = autoSync ? "AUTO" : "MANUAL";
return SyncStrat[strat];
}
@ -64,25 +53,9 @@ export function maybeNegateStatus(x: OverrideHints): SyncStatus | undefined {
switch (determineStrategy({ autoSync, fbosVersion })) {
case SyncStrat.AUTO:
return "syncing";
case SyncStrat.LEGACY:
case SyncStrat.MANUAL:
return "sync_now";
case SyncStrat.OFFLINE:
return "unknown";
}
}
/** Legacy bots dont have enough info to unset their `consistency` flag.
* TODO: Delete this method in April 2018.
*/
export function maybeNegateConsistency(x: OverrideHints): boolean {
const { autoSync, fbosVersion, consistent, syncStatus } = x;
switch (determineStrategy({ autoSync, fbosVersion })) {
case SyncStrat.LEGACY:
// Manually flip `consistent` off when bot sends `syncing` msg.
// All others can be handled as usual.
return (syncStatus === "syncing") ? true : consistent;
default:
return consistent;
}
}

View File

@ -4,24 +4,25 @@ import { isNumber } from "lodash";
import axios from "axios";
import { API } from "../api/index";
import { timestamp } from "../util";
import { FarmBotInternalConfig } from "farmbot/dist/config";
export const PING_INTERVAL = 3000;
export const ACTIVE_THRESHOLD = PING_INTERVAL * 2;
const label = "ping";
export const LAST_IN = "LAST_PING_IN";
export const LAST_OUT = "LAST_PING_OUT";
export const LAST_IN: keyof FarmBotInternalConfig = "LAST_PING_IN";
export const LAST_OUT: keyof FarmBotInternalConfig = "LAST_PING_OUT";
export const PING: Readonly<RpcRequest> = { kind: "rpc_request", args: { label } };
type Direction = "in" | "out";
export function writePing(bot: Farmbot, direction: Direction) {
const dir = direction === "out" ? LAST_OUT : LAST_IN;
bot.setState(dir, timestamp());
bot.setConfig(dir, timestamp());
}
export function readPing(bot: Farmbot, direction: Direction): number | undefined {
const val = bot.getState()[direction === "out" ? LAST_OUT : LAST_IN];
const val = bot.getConfig(direction === "out" ? LAST_OUT : LAST_IN);
return isNumber(val) ? val : undefined;
}

View File

@ -133,8 +133,12 @@ export namespace ToolTips {
export const ENABLE_ENDSTOPS =
trim(`Enable use of electronic end-stops during calibration and homing.`);
export const SWAP_ENDPOINTS =
trim(`Swap axis minimum and maximum end-stops.`);
export const INVERT_ENDPOINTS =
trim(`Swap axis end-stops during calibration.`);
trim(`Invert axis end-stops. Enable for normally closed (NC),
disable for normally open (NO).`);
// Hardware Settings: Pin Guard
export const PIN_GUARD_PIN_NUMBER =
@ -313,6 +317,13 @@ export namespace Content {
trim(`If you are sure you want to delete your account, type in
your password below to continue.`);
export const EXPORT_DATA_DESC =
trim(`Export all data related to this device. Exports are delivered via
email as JSON.`);
export const EXPORT_SENT =
trim(`Export request received. Please allow up to 10 minutes for
delivery.`);
// App Settings
export const CONFIRM_STEP_DELETION =
trim(`Show a confirmation dialog when the sequence delete step

View File

@ -2,19 +2,6 @@ jest.mock("react-redux", () => ({
connect: jest.fn()
}));
const mockStorj: Dictionary<boolean> = {};
jest.mock("../../session", () => {
return {
Session: {
deprecatedGetBool: (k: string) => {
mockStorj[k] = !!mockStorj[k];
return mockStorj[k];
}
}
};
});
import * as React from "react";
import { mount } from "enzyme";
import { Controls } from "../controls";
@ -23,10 +10,11 @@ import {
fakePeripheral, fakeWebcamFeed, fakeSensor
} from "../../__test_support__/fake_state/resources";
import { Dictionary } from "farmbot";
import { BooleanSetting } from "../../session_keys";
import { Props } from "../interfaces";
describe("<Controls />", () => {
const mockConfig: Dictionary<boolean> = {};
function fakeProps(): Props {
return {
dispatch: jest.fn(),
@ -38,12 +26,12 @@ describe("<Controls />", () => {
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
shouldDisplay: () => true,
xySwap: false,
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
};
}
it("shows webcam widget", () => {
mockStorj[BooleanSetting.hide_webcam_widget] = false;
mockConfig.hide_webcam_widget = false;
const wrapper = mount(<Controls {...fakeProps()} />);
const txt = wrapper.text().toLowerCase();
["webcam", "move", "peripherals", "sensors"]
@ -51,7 +39,7 @@ describe("<Controls />", () => {
});
it("hides webcam widget", () => {
mockStorj[BooleanSetting.hide_webcam_widget] = true;
mockConfig.hide_webcam_widget = true;
const wrapper = mount(<Controls {...fakeProps()} />);
const txt = wrapper.text().toLowerCase();
["move", "peripherals", "sensors"]

View File

@ -1,5 +1,6 @@
const mockDevice = {
home: jest.fn(() => { return Promise.resolve(); }),
findHome: jest.fn(() => { return Promise.resolve(); }),
takePhoto: jest.fn(() => { return Promise.resolve(); }),
moveRelative: jest.fn(() => { return Promise.resolve(); }),
};
@ -29,6 +30,7 @@ describe("<JogButtons/>", function () {
arduinoBusy: false,
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
doFindHome: false,
};
};
@ -38,6 +40,14 @@ describe("<JogButtons/>", function () {
expect(mockDevice.home).toHaveBeenCalledTimes(1);
});
it("calls find home command", () => {
const p = jogButtonProps();
p.doFindHome = true;
const jogButtons = mount(<JogButtons {...p} />);
jogButtons.find("button").at(3).simulate("click");
expect(mockDevice.findHome).toHaveBeenCalledTimes(1);
});
it("is disabled", () => {
const p = jogButtonProps();
p.arduinoBusy = true;

View File

@ -1,11 +1,10 @@
jest.mock("../../session", () => {
return {
Session: {
deprecatedGetBool: jest.fn(),
invertBool: jest.fn()
}
};
});
const mockDevice = {
moveAbsolute: jest.fn(() => { return Promise.resolve(); }),
};
jest.mock("../../device", () => ({
getDevice: () => (mockDevice)
}));
jest.mock("../../config_storage/actions", () => {
return {
@ -14,32 +13,31 @@ jest.mock("../../config_storage/actions", () => {
});
import * as React from "react";
import { mount } from "enzyme";
import { mount, shallow } from "enzyme";
import { Move } from "../move";
import { bot } from "../../__test_support__/fake_state/bot";
import { MoveProps } from "../interfaces";
import { Session } from "../../session";
import { toggleWebAppBool } from "../../config_storage/actions";
import { Dictionary } from "farmbot";
import { BooleanSetting } from "../../session_keys";
import { Actions } from "../../constants";
describe("<Move />", () => {
beforeEach(function () {
jest.clearAllMocks();
});
const mockConfig: Dictionary<boolean> = {};
function fakeProps(): MoveProps {
return {
dispatch: jest.fn(),
bot: bot,
user: undefined,
arduinoBusy: false,
raw_encoders: false,
scaled_encoders: false,
x_axis_inverted: false,
y_axis_inverted: false,
z_axis_inverted: false,
botToMqttStatus: "up",
firmwareSettings: bot.hardware.mcu_params,
xySwap: false,
getWebAppConfigVal: jest.fn((key) => (mockConfig[key])),
};
}
@ -52,7 +50,7 @@ describe("<Move />", () => {
it("has only raw encoder data display", () => {
const p = fakeProps();
p.raw_encoders = true;
mockConfig.raw_encoders = true;
const wrapper = mount(<Move {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("raw");
@ -61,8 +59,8 @@ describe("<Move />", () => {
it("has both encoder data displays", () => {
const p = fakeProps();
p.raw_encoders = true;
p.scaled_encoders = true;
mockConfig.raw_encoders = true;
mockConfig.scaled_encoders = true;
const wrapper = mount(<Move {...p} />);
const txt = wrapper.text().toLowerCase();
expect(txt).toContain("raw");
@ -73,23 +71,27 @@ describe("<Move />", () => {
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle("x")();
expect(Session.invertBool).toHaveBeenCalledWith("x_axis_inverted");
instance.toggle(BooleanSetting.xy_swap)();
expect(toggleWebAppBool).toHaveBeenCalledWith(BooleanSetting.xy_swap);
});
it("toggle: encoder data display", () => {
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle_encoder_data("raw_encoders")();
expect(Session.invertBool).toHaveBeenCalledWith("raw_encoders");
it("changes step size", () => {
const p = fakeProps();
const wrapper = mount(<Move {...p} />);
const btn = wrapper.find("button").first();
expect(btn.text()).toEqual("1");
btn.simulate("click");
expect(p.dispatch).toHaveBeenCalledWith({
type: Actions.CHANGE_STEP_SIZE,
payload: 1
});
});
it("toggle: xy swap", () => {
const wrapper = mount(<Move {...fakeProps()} />);
// tslint:disable-next-line:no-any
const instance = wrapper.instance() as any;
instance.toggle_xy_swap();
expect(toggleWebAppBool).toHaveBeenCalledWith("xy_swap");
it("inputs axis destination", () => {
const p = fakeProps();
const wrapper = shallow(<Move {...p} />);
const axisInput = wrapper.find("AxisInputBoxGroup");
axisInput.simulate("commit", "123");
expect(mockDevice.moveAbsolute).toHaveBeenCalledWith("123");
});
});

View File

@ -8,7 +8,6 @@ import { WebcamPanel } from "./webcam";
import { Props, MoveProps } from "./interfaces";
import { Move } from "./move";
import { BooleanSetting } from "../session_keys";
import { Session } from "../session";
import { Feature } from "../devices/interfaces";
@connect(mapStateToProps)
@ -25,16 +24,11 @@ export class Controls extends React.Component<Props, {}> {
user: this.props.user,
dispatch: this.props.dispatch,
arduinoBusy,
raw_encoders: !!Session.deprecatedGetBool(BooleanSetting.raw_encoders),
scaled_encoders: !!Session.deprecatedGetBool(BooleanSetting.scaled_encoders),
x_axis_inverted: !!Session.deprecatedGetBool(BooleanSetting.x_axis_inverted),
y_axis_inverted: !!Session.deprecatedGetBool(BooleanSetting.y_axis_inverted),
z_axis_inverted: !!Session.deprecatedGetBool(BooleanSetting.z_axis_inverted),
botToMqttStatus: this.props.botToMqttStatus,
firmwareSettings: this.props.firmwareSettings,
xySwap: this.props.xySwap,
getWebAppConfigVal: this.props.getWebAppConfigVal,
};
const showWebcamWidget = !Session.deprecatedGetBool(BooleanSetting.hide_webcam_widget);
const showWebcamWidget = !this.props.getWebAppConfigVal(BooleanSetting.hide_webcam_widget);
return <Page className="controls">
{showWebcamWidget
?

View File

@ -1,7 +1,7 @@
import * as React from "react";
import { Farmbot } from "farmbot";
import { moveRelative } from "../devices/actions";
import { DirectionButtonProps, Payl } from "./interfaces";
import { CONFIG_DEFAULTS } from "farmbot/dist/config";
export function directionDisabled(props: DirectionButtonProps): boolean {
const {
@ -42,7 +42,7 @@ export function calculateDistance(props: DirectionButtonProps) {
export class DirectionButton extends React.Component<DirectionButtonProps, {}> {
sendCommand = () => {
const payload: Payl = { speed: Farmbot.defaults.speed, x: 0, y: 0, z: 0 };
const payload: Payl = { speed: CONFIG_DEFAULTS.speed, x: 0, y: 0, z: 0 };
payload[this.props.axis] = calculateDistance(this.props);
moveRelative(payload);
}

Some files were not shown because too many files have changed in this diff Show More