Merge branch 'staging' into master
commit
e27d149e43
|
@ -21,3 +21,4 @@ public/system
|
|||
public/webpack
|
||||
public/webpack/*
|
||||
tmp
|
||||
public/direct_upload/temp/*.jpg
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
class LegacyGenericPointer < ApplicationRecord
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
|
@ -1,5 +0,0 @@
|
|||
class LegacyPlant < ApplicationRecord
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
|
@ -1,5 +0,0 @@
|
|||
class LegacyToolSlot < ApplicationRecord
|
||||
def broadcast?
|
||||
false
|
||||
end
|
||||
end
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
We have received your request to export your FarmBot account data.
|
||||
|
||||
Please see attached account data.
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
61
db/schema.rb
61
db/schema.rb
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 : {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
FactoryBot.define do
|
||||
factory :fbos_config do
|
||||
end
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
FactoryBot.define do
|
||||
factory :firmware_config do
|
||||
end
|
||||
end
|
|
@ -7,6 +7,5 @@ FactoryBot.define do
|
|||
meta ({})
|
||||
device
|
||||
pointer_type GenericPointer.name
|
||||
pointer_id(0)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,5 @@ FactoryBot.define do
|
|||
device
|
||||
openfarm_slug "lettuce"
|
||||
pointer_type "Plant"
|
||||
pointer_id 0
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,6 +7,5 @@ FactoryBot.define do
|
|||
device
|
||||
tool
|
||||
pointer_type("ToolSlot")
|
||||
pointer_id(0)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
FactoryBot.define do
|
||||
factory :web_app_config do
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
require "spec_helper"
|
||||
|
||||
describe DataDumpMailer, type: :mailer do
|
||||
it 'sends a JSON file to users'
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
# Preview all emails at http://localhost:3000/rails/mailers/data_dump
|
||||
class DataDumpPreview < ActionMailer::Preview
|
||||
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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>;
|
||||
}
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
|
@ -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 */
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)", () => {
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
?
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue