diff --git a/.circleci/config.yml b/.circleci/config.yml index 9cd732e3c1..a2dfec6c19 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -115,6 +115,7 @@ commands: workflows: version: 2 build_and_test: + max_auto_reruns: 1 jobs: #- build - all diff --git a/.eslintignore b/.eslintignore index 3b7973f239..4008f52cc8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ hacks.d.ts .eslintrc.js +frontend/wizard/step.tsx diff --git a/.ruby-version b/.ruby-version index f9892605c7..4f5e69734c 100755 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +3.4.5 diff --git a/Gemfile b/Gemfile index 47c474e4af..7bac44de02 100755 --- a/Gemfile +++ b/Gemfile @@ -1,5 +1,5 @@ source "https://rubygems.org" -ruby "~> 3.4.4" +ruby "~> 3.4.5" gem "rails", "~> 6" gem "active_model_serializers" @@ -32,6 +32,7 @@ gem "drb" gem "benchmark" gem "ostruct" gem "bigdecimal" +gem "mutex_m" group :development, :test do gem "climate_control" diff --git a/Gemfile.lock b/Gemfile.lock index 1261d23699..8f7a4554c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -68,10 +68,10 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) amq-protocol (2.3.4) - base64 (0.2.0) + base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.0) - bigdecimal (3.1.9) + benchmark (0.4.1) + bigdecimal (3.2.2) builder (3.3.0) bunny (2.24.0) amq-protocol (~> 2.3) @@ -87,9 +87,9 @@ GEM crass (1.0.6) database_cleaner (2.1.0) database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.2.1) + database_cleaner-active_record (2.2.2) activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) + database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) date (3.4.1) declarative (0.0.20) @@ -113,34 +113,34 @@ GEM drb (2.2.3) e2mmap (0.1.0) erubi (1.13.1) - factory_bot (6.5.1) + factory_bot (6.5.5) activesupport (>= 6.1.0) - factory_bot_rails (6.4.4) + factory_bot_rails (6.5.0) factory_bot (~> 6.5) - railties (>= 5.0.0) - faker (3.5.1) + railties (>= 6.1.0) + faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.13.4) faraday-net_http (>= 2.0, < 3.5) json logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-net_http (3.4.0) + faraday-net_http (3.4.1) net-http (>= 0.5.0) globalid (1.2.1) activesupport (>= 6.1) - google-apis-core (0.18.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) - mini_mime (~> 1.0) - mutex_m + google-apis-core (1.0.1) + addressable (~> 2.8, >= 2.8.7) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) representable (~> 3.0) - retriable (>= 2.0, < 4.a) + retriable (~> 3.1) google-apis-iamcredentials_v1 (0.24.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.51.0) + google-apis-storage_v1 (0.56.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -149,33 +149,31 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.5.0) - google-cloud-storage (1.56.0) + google-cloud-storage (1.57.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-core (~> 0.13) + google-apis-core (>= 0.18, < 2) google-apis-iamcredentials_v1 (~> 0.18) google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.14.0) + googleauth (1.15.0) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) - jwt (>= 1.4, < 3.0) + jwt (>= 1.4, < 4.0) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.2.0) hashie (4.1.0) - httpclient (2.9.0) - mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.12.2) + json (2.13.2) jsonapi-renderer (0.2.2) - jwt (2.10.1) + jwt (3.1.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -207,13 +205,13 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) - multi_json (1.15.0) + multi_json (1.17.0) mutations (0.9.1) activesupport mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.8) + net-imap (0.5.10) date net-protocol net-pop (0.1.2) @@ -223,18 +221,19 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.8-aarch64-linux-gnu) + nokogiri (1.18.9-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.3) passenger (6.0.27) rack (>= 1.6.13) rackup (>= 1.0.1) rake (>= 12.3.3) - pg (1.5.9) + pg (1.6.2-aarch64-linux) + pg (1.6.2-x86_64-linux) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -248,7 +247,7 @@ GEM hashie (~> 4.1) multi_json (~> 1.15) racc (1.8.1) - rack (2.2.16) + rack (2.2.17) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -291,7 +290,7 @@ GEM method_source rake (>= 12.2) thor (~> 1.0) - rake (13.2.1) + rake (13.3.0) rbtree (0.4.6) redis (4.8.1) representable (3.2.0) @@ -304,18 +303,18 @@ GEM actionpack (>= 5.2) railties (>= 5.2) retriable (3.1.2) - rexml (3.4.1) + rexml (3.4.2) rollbar (3.6.2) - rspec (3.13.0) + rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.3) + rspec-core (3.13.5) rspec-support (~> 3.13.0) - rspec-expectations (3.13.4) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.4) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (6.1.5) @@ -326,27 +325,27 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.3) + rspec-support (3.13.5) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - scenic (1.8.0) + scenic (1.9.0) activerecord (>= 4.0.0) railties (>= 4.0.0) secure_headers (7.1.0) set (1.1.2) - signet (0.20.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-cobertura (2.1.0) + simplecov-cobertura (3.1.0) rexml simplecov (~> 0.19) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) sorted_set (1.0.3) rbtree @@ -359,7 +358,7 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - thor (1.3.2) + thor (1.4.0) thwait (0.2.0) e2mmap timeout (0.4.3) @@ -411,6 +410,7 @@ DEPENDENCIES logger lograge mutations + mutex_m ostruct passenger pg @@ -438,7 +438,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.5p51 BUNDLED WITH - 2.6.9 + 2.7.1 diff --git a/app/controllers/api/ais_controller.rb b/app/controllers/api/ais_controller.rb index 844e597257..e10eab62a9 100644 --- a/app/controllers/api/ais_controller.rb +++ b/app/controllers/api/ais_controller.rb @@ -147,6 +147,15 @@ def make_request(system_prompt, user_prompt, stream) missed = true end boundary = buffer.index("\n\n") + begin + err_msg = JSON.parse(buffer)["error"] + puts "AI #{context_key} error:" \ + " (#{err_msg})" unless Rails.env.test? + current_device.tell("Please try again", ["toast"], "error") + return {"error" => {"message" => err_msg}} + rescue JSON::ParserError + nil + end while not boundary.nil? data_str = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2, buffer.length) diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 62a145ef8d..4a4bdae8c3 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ApplicationController before_action :set_global_config + skip_before_action :verify_authenticity_token, only: [:csp_reports] layout "dashboard" # === THESE CONSTANTS ARE CONFIGURABLE: === @@ -102,10 +103,16 @@ def csp_reports payload = request.body.read || "" begin report = JSON.parse(payload) - rescue - report = { problem: "Crashed while parsing report" } + rescue JSON::ParserError => e + report = { + error: "CSP report parse error", + exception: e.message, + raw: payload, + } end - render json: report + + Rollbar.info("CSP Violation Report", report) + head :no_content end # (for self hosted users) Direct image upload endpoint. diff --git a/app/models/celery_script_settings_bag.rb b/app/models/celery_script_settings_bag.rb index 72c554d576..e1ec2631fc 100644 --- a/app/models/celery_script_settings_bag.rb +++ b/app/models/celery_script_settings_bag.rb @@ -24,6 +24,10 @@ def self.exists?(id) ALLOWED_ASSERTION_TYPES = %w(abort recover abort_recover continue) ALLOWED_AXIS = %w(x y z all) + ALLOWED_GROUPING = %w(xyz x,yz yz,x y,xz xz,y z,xy xy,z + x,y,z x,z,y y,x,z y,z,x z,x,y z,y,x + xy x,y y,x xz x,z z,x yz y,z z,y x y z) + ALLOWED_ROUTE = %w(high low in_order) ALLOWED_CHANGES = %w(add remove update) ALLOWED_CHANNEL_NAMES = %w(ticker toast email espeak) ALLOWED_LHS_STRINGS = [*(0..69)].map { |x| "pin#{x}" }.concat(%w(x y z)) @@ -56,6 +60,8 @@ def self.exists?(id) BAD_ASSERTION_TYPE = '"%s" is not a valid assertion type. ' \ "Try these instead: %s" BAD_AXIS = '"%s" is not a valid axis. Allowed values: %s' + BAD_GROUPING = '"%s" is not a valid grouping. Allowed values: %s' + BAD_ROUTE = '"%s" is not a valid route. Allowed values: %s' BAD_CHANNEL_NAME = '"%s" is not a valid channel_name. Allowed values: %s' BAD_LHS = 'Can not put "%s" into a left hand side (LHS) argument. ' \ "Allowed values: %s" @@ -112,6 +118,8 @@ def self.exists?(id) CORPUS_ENUM = { ALLOWED_AXIS: [ALLOWED_AXIS, BAD_AXIS], + ALLOWED_GROUPING: [ALLOWED_GROUPING, BAD_GROUPING], + ALLOWED_ROUTE: [ALLOWED_ROUTE, BAD_ROUTE], ALLOWED_SPECIAL_VALUE: [ALLOWED_SPECIAL_VALUE, BAD_SPECIAL_VALUE], ALLOWED_CHANNEL_NAMES: [ALLOWED_CHANNEL_NAMES, BAD_CHANNEL_NAME], ALLOWED_MESSAGE_TYPES: [ALLOWED_MESSAGE_TYPES, BAD_MESSAGE_TYPE], @@ -206,6 +214,8 @@ def self.v(symbol) url: { defn: [v(:string)] }, value: { defn: [v(:string), v(:integer), v(:boolean)] }, variance: { defn: [v(:integer)] }, + grouping: { defn: [e(:ALLOWED_GROUPING)] }, + route: { defn: [e(:ALLOWED_ROUTE)] }, version: { defn: [v(:integer)] }, x: { defn: [v(:integer), v(:float)] }, y: { defn: [v(:integer), v(:float)] }, @@ -557,6 +567,10 @@ def self.v(symbol) args: [], tags: [:data], }, + axis_order: { + args: [:grouping, :route], + tags: [:data], + }, random: { args: [:variance], tags: [:data], @@ -566,6 +580,7 @@ def self.v(symbol) :axis_overwrite, :axis_addition, :speed_overwrite, + :axis_order, :safe_z, ], tags: [:function, :firmware_user], diff --git a/app/mutations/auth/create_token_from_credentials.rb b/app/mutations/auth/create_token_from_credentials.rb index c682129ddd..2ab7289da5 100644 --- a/app/mutations/auth/create_token_from_credentials.rb +++ b/app/mutations/auth/create_token_from_credentials.rb @@ -19,7 +19,7 @@ def validate whoops! unless maybe_user @user = maybe_user end - rescue OpenSSL::PKey::RSAError => e + rescue OpenSSL::PKey::RSAError, JSON::ParserError whoops!(BAD_KEY) end diff --git a/app/mutations/devices/seeders/abstract_express.rb b/app/mutations/devices/seeders/abstract_express.rb index adf301e2d6..4f12a99793 100644 --- a/app/mutations/devices/seeders/abstract_express.rb +++ b/app/mutations/devices/seeders/abstract_express.rb @@ -75,6 +75,8 @@ def sequences_mount_tool; end def sequences_dismount_tool; end def sequences_mow_all_weeds; end def sequences_pick_from_seed_tray; end + def sequences_pick_from_seed_trough; end + def sequences_pick_from_seed_bin; end def sequences_pick_up_seed s = SequenceSeeds::PICK_UP_SEED_EXPRESS.deep_dup diff --git a/app/mutations/devices/seeders/abstract_genesis.rb b/app/mutations/devices/seeders/abstract_genesis.rb index c8528582f8..a4265d0fe8 100644 --- a/app/mutations/devices/seeders/abstract_genesis.rb +++ b/app/mutations/devices/seeders/abstract_genesis.rb @@ -131,28 +131,24 @@ def sequences_pick_from_seed_tray end end - def sequences_pick_up_seed - s = SequenceSeeds::PICK_UP_SEED_GENESIS.deep_dup - - seed_bin_id = device.tools.find_by!(name: ToolNames::SEED_BIN).id - mount_tool_id = device.sequences.find_by!(name: PublicSequenceNames::MOUNT_TOOL).id - - s.dig(:body, 0, :args)[:sequence_id] = mount_tool_id - s.dig(:body, 0, :body, 0, :args, :data_value, :args)[:tool_id] = seeder_id - s.dig(:body, 1, :body, 0, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 1, :body, 1, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 1, :body, 2, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 2, :args, :pin_number, :args)[:pin_id] = vacuum_id - s.dig(:body, 3, :body, 0, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 3, :body, 1, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 3, :body, 2, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 4, :body, 0, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 4, :body, 1, :args, :axis_operand, :args)[:tool_id] = seed_bin_id - s.dig(:body, 4, :body, 2, :args, :axis_operand, :args)[:tool_id] = seed_bin_id + def sequences_pick_from_seed_trough + success = install_sequence_version_by_name(PublicSequenceNames::PICK_FROM_SEED_TROUGH) + if !success + s = SequenceSeeds::PICK_FROM_SEED_TROUGH.deep_dup + Sequences::Create.run!(s, device: device) + end + end - Sequences::Create.run!(s, device: device) + def sequences_pick_from_seed_bin + success = install_sequence_version_by_name(PublicSequenceNames::PICK_FROM_SEED_BIN) + if !success + s = SequenceSeeds::PICK_FROM_SEED_BIN.deep_dup + Sequences::Create.run!(s, device: device) + end end + def sequences_pick_up_seed; end + def sequences_plant_seed s = SequenceSeeds::PLANT_SEED_GENESIS.deep_dup diff --git a/app/mutations/devices/seeders/abstract_seeder.rb b/app/mutations/devices/seeders/abstract_seeder.rb index c1cd8fb13a..e7deff99df 100644 --- a/app/mutations/devices/seeders/abstract_seeder.rb +++ b/app/mutations/devices/seeders/abstract_seeder.rb @@ -78,6 +78,8 @@ class AbstractSeeder :sequences_dispense_water, :sequences_mow_all_weeds, :sequences_pick_from_seed_tray, + :sequences_pick_from_seed_trough, + :sequences_pick_from_seed_bin, ] def initialize(device) @@ -114,6 +116,8 @@ def sequences_pick_up_seed; end def sequences_plant_seed; end def sequences_mow_all_weeds; end def sequences_pick_from_seed_tray; end + def sequences_pick_from_seed_trough; end + def sequences_pick_from_seed_bin; end def sequences_take_photo_of_plant s = SequenceSeeds::TAKE_PHOTO_OF_PLANT.deep_dup @@ -306,7 +310,7 @@ def add_point_group(name:, pointer_type: "Plant", openfarm_slug: nil) PointGroups::Create.run!(device: device, name: name, point_ids: [], - sort_type: "yx_ascending", + sort_type: "yx_alternating", criteria: { string_eq: { pointer_type: [pointer_type], @@ -319,10 +323,6 @@ def add_point_group(name:, pointer_type: "Plant", openfarm_slug: nil) }) end - def seeder_id - @seeder_id ||= device.tools.find_by!(name: ToolNames::SEEDER).id - end - def water_plant_id @water_plant_id ||= device.sequences.find_by!(name: "Water plant").id end diff --git a/app/mutations/devices/seeders/constants.rb b/app/mutations/devices/seeders/constants.rb index f70e39aa88..5cddadcc40 100644 --- a/app/mutations/devices/seeders/constants.rb +++ b/app/mutations/devices/seeders/constants.rb @@ -10,7 +10,7 @@ module Constants TOOL_SPACING = 100 TROUGH_Y = 0 - TROUGH_Z = -200 + TROUGH_Z = -300 TROUGH_SPACING = 25 module Names @@ -44,7 +44,6 @@ module ToolNames module SequenceSeeds ALL = YAML.load(File.read(SEQUENCE_FIXTURE_PATH)) PICK_UP_SEED_EXPRESS = ALL.fetch(:PICK_UP_SEED_EXPRESS) - PICK_UP_SEED_GENESIS = ALL.fetch(:PICK_UP_SEED_GENESIS) PLANT_SEED_GENESIS = ALL.fetch(:PLANT_SEED_GENESIS) PLANT_SEED_EXPRESS = ALL.fetch(:PLANT_SEED_EXPRESS) TAKE_PHOTO_OF_PLANT = ALL.fetch(:TAKE_PHOTO_OF_PLANT) @@ -62,19 +61,23 @@ module SequenceSeeds WEED_DETECTION_GRID = ALL.fetch(:WEED_DETECTION_GRID) MOW_ALL_WEEDS = ALL.fetch(:MOW_ALL_WEEDS) PICK_FROM_SEED_TRAY = ALL.fetch(:PICK_FROM_SEED_TRAY) + PICK_FROM_SEED_TROUGH = ALL.fetch(:PICK_FROM_SEED_TROUGH) + PICK_FROM_SEED_BIN = ALL.fetch(:PICK_FROM_SEED_BIN) end module PublicSequenceNames DISPENSE_WATER = "Dispense Water" SOIL_HEIGHT_GRID = "Soil Height Grid" GRID = "Grid" - WATER_ALL = "Water All" + WATER_ALL = "Water all" PHOTO_GRID = "Photo Grid" WEED_DETECTION_GRID = "Weed Detection Grid" MOUNT_TOOL = "Mount Tool" DISMOUNT_TOOL = "Dismount Tool" MOW_ALL_WEEDS = "Mow All Weeds" PICK_FROM_SEED_TRAY = "Pick from Seed Tray" + PICK_FROM_SEED_TROUGH = "Pick from Seed Trough" + PICK_FROM_SEED_BIN = "Pick from Seed Bin" end end end diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 908a09b963..332503ca4e 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -20,12 +20,31 @@ def feed(product_line) def create_webcam_feed(product_line) feed_name = feed(product_line) - WebcamFeeds::Create.run!({ name: feed_name, - url: BASE_URL + FEEDS[feed_name], - device: device }) + if feed_name != "" + WebcamFeeds::Create.run!({ name: feed_name, + url: BASE_URL + FEEDS.fetch(feed_name, ""), + device: device }) + end + end + + def add_curves + Curves::Create.run!( + device: device, + name: "Spinach water curve", + type: "water", + data: { 1 => 200, 30 => 500, 40 => 500, 45 => 300, 60 => 300 }, + ) + Curves::Create.run!( + device: device, + name: "Broccoli water curve", + type: "water", + data: { 1 => 300, 45 => 1200, 60 => 1200, 65 => 900, 75 => 900 }, + ) end def add_plants(product_line) + spinach_curve = device.curves.find_by!(name: "Spinach water curve") + broccoli_curve = device.curves.find_by!(name: "Broccoli water curve") spinach_row_count = product_line.include?("xl") ? 28 : 13 spinach_col_count = product_line.include?("genesis_xl") ? 4 : 2 (0..(spinach_row_count - 1)).map do |i| @@ -34,7 +53,9 @@ def add_plants(product_line) pointer_type: "Plant", name: "Spinach", openfarm_slug: "spinach", - plant_stage: "planned", + plant_stage: "planted", + planted_at: Time.now, + water_curve_id: spinach_curve.id, x: 400 + i * 200, y: 100 + j * 200 + (j > 1 ? 2100 : 0), z: 0) @@ -54,7 +75,9 @@ def add_plants(product_line) pointer_type: "Plant", name: "Broccoli", openfarm_slug: "broccoli", - plant_stage: "planned", + plant_stage: "planted", + planted_at: Time.now, + water_curve_id: broccoli_curve.id, x: 600 + i * 600, y: 700 + j * 600 + (j > 0 ? 300 : 0), z: 0) @@ -97,6 +120,18 @@ def add_point_groups add_point_group(name: "Beet plants", openfarm_slug: "beet") end + def add_envs + [ + ["CAMERA_CALIBRATION_coord_scale", "1"], + ["CAMERA_CALIBRATION_center_pixel_location_x", "320"], + ["CAMERA_CALIBRATION_center_pixel_location_y", "240"], + ].each do |key, value| + FarmwareEnvs::Create.run( + { key: key, value: value }, + device: device) + end + end + def marketing_bulletin GlobalBulletin.find_or_create_by(slug: "buy-a-farmbot") do |gb| gb.href = "https://farm.bot" @@ -138,8 +173,13 @@ def before_product_line_seeder .update!( discard_unsaved: true, three_d_garden: true, - show_points: false ) + device + .fbos_config + .update!( + safe_height: -150, + ) + add_curves end def after_product_line_seeder(product_line) @@ -147,6 +187,9 @@ def after_product_line_seeder(product_line) add_plants(product_line) add_soil_height_points(product_line) add_point_groups + tool = device.tools.find_by(name: ToolNames::WATERING_NOZZLE) + Tools::Update.run(tool: tool, flow_rate_ml_per_s: 100) if tool + add_envs marketing_bulletin device.alerts.where(problem_tag: UNUSED_ALERTS).destroy_all diff --git a/app/mutations/devices/seeders/none.rb b/app/mutations/devices/seeders/none.rb index ec697cd548..0a0888962f 100644 --- a/app/mutations/devices/seeders/none.rb +++ b/app/mutations/devices/seeders/none.rb @@ -20,6 +20,8 @@ def sequences_take_photo_of_plant; end def sequences_water_plant; end def sequences_mow_all_weeds; end def sequences_pick_from_seed_tray; end + def sequences_pick_from_seed_trough; end + def sequences_pick_from_seed_bin; end def point_groups_all_plants; end def point_groups_all_points; end def point_groups_all_weeds; end diff --git a/app/mutations/devices/seeders/sequence_fixtures.yml b/app/mutations/devices/seeders/sequence_fixtures.yml index 81de137d36..778a23f275 100644 --- a/app/mutations/devices/seeders/sequence_fixtures.yml +++ b/app/mutations/devices/seeders/sequence_fixtures.yml @@ -534,147 +534,6 @@ :number: 50 :comment: Move above seed trough -:PICK_UP_SEED_GENESIS: # ========================= - :args: - :locals: - :kind: scope_declaration - :args: {} - :color: yellow - :name: Pick up seed - :body: - - :kind: execute - :args: - :sequence_id: 0 - :body: - - :kind: parameter_application - :args: - :label: Tool - :data_value: - :kind: tool - :args: - :tool_id: 0 - :comment: Mount seeder tool - - :kind: move - :args: {} - :body: - - :kind: axis_overwrite - :args: - :axis: x - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: y - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: z - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_addition - :args: - :axis: z - :axis_operand: - :kind: numeric - :args: - :number: 150 - :comment: Move above seed bin - - :kind: write_pin - :args: - :pin_value: 1 - :pin_mode: 0 - :pin_number: - :kind: named_pin - :args: - :pin_type: Peripheral - :pin_id: 0 - :comment: Turn on vacuum pump - - :kind: move - :args: {} - :body: - - :kind: axis_overwrite - :args: - :axis: x - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: y - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: z - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_addition - :args: - :axis: z - :axis_operand: - :kind: numeric - :args: - :number: 100 - - :kind: speed_overwrite - :args: - :axis: z - :speed_setting: - :kind: numeric - :args: - :number: 25 - :comment: Pick up seed - - :kind: move - :args: {} - :body: - - :kind: axis_overwrite - :args: - :axis: x - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: y - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_overwrite - :args: - :axis: z - :axis_operand: - :kind: tool - :args: - :tool_id: 0 - - :kind: axis_addition - :args: - :axis: z - :axis_operand: - :kind: numeric - :args: - :number: 150 - - :kind: speed_overwrite - :args: - :axis: z - :speed_setting: - :kind: numeric - :args: - :number: 50 - :comment: Move above seed bin - :WATER_ALL_PLANTS: # ========================= :args: :locals: @@ -746,7 +605,8 @@ :body: - :kind: lua :args: - :lua: "mount_tool(variable(\"Tool\"))" + :lua: | + mount_tool(variable("Tool")) :DISMOUNT_TOOL: # =================================== :args: @@ -758,27 +618,40 @@ :body: - :kind: lua :args: - :lua: "dismount_tool()" + :lua: | + dismount_tool() :WATER_ALL: # =================================== :args: :locals: :kind: scope_declaration :args: {} - :body: - - :kind: parameter_declaration - :args: - :label: Watering Time (Seconds) - :default_value: - :kind: numeric - :args: - :number: 2 :color: blue - :name: Water All + :name: Water all :body: - :kind: lua :args: - :lua: "local watering_time = variable(\"Watering Time (Seconds)\")\nstart_time = os.time() * 1000\n\nlocal points = api({method = \"GET\", url = \"/api/points\"})\n\nlocal plants = {}\n\nfor k, v in pairs(points) do\n if v.pointer_type == \"Plant\" then\n table.insert(plants, {name = v.name, x = v.x, y = v.y})\n end\nend\n\ntable.sort(plants, function(l, r)\n -- \"close enough\" approximation.\n if math.abs(l.x - r.x) < 150 then\n return l.y < r.y\n else\n return l.x < r.x\n end\nend)\n\ncount = 0\ntotal = #plants\njob = \"Watering all \" .. total .. \" plants\"\n\nsend_message(\n \"info\",\n \"Watering all \" .. total .. \" plants for \" .. watering_time .. \" seconds each\",\n \"toast\")\n\nfor k, v in pairs(plants) do\n coordinates = \"(\" .. v.x .. \", \" .. v.y .. \")\"\n set_job_progress(job, {\n percent = 100 * (count) / total,\n status = \"Moving to \" .. (v.name or \"plant\") .. \" at \" .. coordinates,\n time = start_time\n })\n move_absolute(v.x, v.y, 0)\n set_job_progress(job, {\n percent = 100 * (count + 0.5) / total,\n status = \"Watering \" .. (v.name or \"plant\") .. \" for \" .. watering_time .. \" seconds\",\n time = start_time\n })\n write_pin(8, \"digital\", 1)\n wait(watering_time * 1000)\n write_pin(8, \"digital\", 0)\n count = count + 1\nend\n\nset_job_progress(job, {\n percent = 100,\n status = \"Complete\",\n time = start_time\n})" + :lua: | + -- Get and sort all planted plants + plants = sort(get_plants(), "nn") + + -- Initialize job and loop + count = 0 + job = "Watering all " .. #plants .. " plants" + toast(job) + + -- Water each plant + for _, plant in pairs(plants) do + set_job(job, { + percent = 100 * (count) / #plants, + status = "Watering " .. (plant.name or "plant") .. " at (" .. plant.x .. ", " .. plant.y .. ")" + }) + water(plant) + count = count + 1 + end + + complete_job(job) + :SOIL_HEIGHT_GRID: # =================================== :args: @@ -790,7 +663,26 @@ :body: - :kind: lua :args: - :lua: "local grid = photo_grid()\ntype = \"Scan\"\njob = \"Soil Height Grid\"\nstart_time = os.time() * 1000\n\ngrid.each(function(cell)\n set_job_progress(job, {\n percent = 100 * (cell.count - 0.5) / grid.total,\n status = \"Moving\",\n time = start_time\n })\n move_absolute(cell.x, cell.y, cell.z)\n set_job_progress(job, {\n percent = 100 * (cell.count / grid.total),\n status = \"Measuring soil height\",\n time = start_time\n })\n local msg = \"Measuring height at point \" .. cell.count .. \" of \" .. grid.total\n send_message(\"info\", msg)\n measure_soil_height()\nend)\n\nset_job_progress(job, {\n percent = 100,\n status = \"Complete\",\n time = start_time\n})" + :lua: | + local grid = photo_grid() + job = "Soil Height Grid" + + grid.each(function(cell) + set_job(job, { + percent = 100 * (cell.count - 0.5) / grid.total, + status = "Moving" + }) + move{x=cell.x, y=cell.y, z=cell.z} + set_job(job, { + percent = 100 * (cell.count / grid.total), + status = "Measuring soil height" + }) + local msg = "Measuring height at point " .. cell.count .. " of " .. grid.total + send_message("info", msg) + measure_soil_height() + end) + + complete_job(job) :PICK_FROM_SEED_TRAY: # =================================== :args: @@ -811,12 +703,204 @@ :kind: text :args: :string: B3 + - :kind: parameter_declaration + :args: + :label: Cell Depth + :default_value: + :kind: numeric + :args: + :number: 5 :color: yellow :name: Pick from Seed Tray :body: - :kind: lua :args: - :lua: "tray = variable(\"Seed Tray\")\ncell_label = variable(\"Seed Tray Cell\")\ncell = get_seed_tray_cell(tray, cell_label)\ncell_depth = 5\nvacuum_pump_pin = 9\n\n-- Checks\nif not verify_tool() then\n return\nend\n\n-- Send message with cell info\nlocal cell_coordinates = \" (\" .. cell.x .. \", \" .. cell.y .. \", \" .. cell.z - cell_depth .. \")\"\nsend_message(\"info\", \"Picking up seed from cell \" .. cell_label .. cell_coordinates, \"toast\")\n\n-- Job\nstart_time = os.time() * 1000\nfunction job(status, percent)\n set_job_progress(\"Pick from Seed Tray\", {\n status = status,\n percent = percent,\n time = start_time\n })\nend\n\n-- Safe Z move to above the cell\njob(\"Moving to Seed Tray\", 25)\nmove_absolute({\n x = cell.x,\n y = cell.y,\n z = cell.z + 25,\n safe_z = true\n})\n\n-- Pick up seed\njob(\"Picking up seed\", 75)\nwrite_pin(vacuum_pump_pin, \"digital\", 1)\nmove_absolute(cell.x, cell.y, cell.z - cell_depth)\n\n-- Retract Z\njob(\"Retracting Z\", 90)\nmove_absolute(cell.x, cell.y, cell.z + 25)\njob(\"Complete\", 100)" + :lua: | + tray = variable("Seed Tray") + cell_label = variable("Seed Tray Cell") + cell = get_seed_tray_cell(tray, cell_label) + cell_depth = variable("Cell Depth") + seeder_tool = get_tool({name = "Seeder"}) + seeder_tip_z_offset = seeder_tool.seeder_tip_z_offset or 80 + vacuum_pump_pin = 9 + + -- Checks + if not verify_tool() then + return + end + + -- Send message with cell info + toast("Picking up seed from cell " .. cell_label) + + -- Job + function job(status, percent) + set_job("Pick from Seed Tray", { + status = status, + percent = percent + }) + end + + -- Move to above the cell + job("Moving to seed tray", 25) + move{ + x = cell.x, + y = cell.y, + z = cell.z + seeder_tip_z_offset + 25, + grouping = "xy,z", + route = "high" + } + + -- Pick up seed + job("Picking up seed", 75) + on(vacuum_pump_pin) + move{ + z = cell.z + seeder_tip_z_offset - cell_depth, + speed = 50 + } + + -- Retract Z + job("Retracting Z", 90) + move{z=cell.z + seeder_tip_z_offset + 25} + job("Complete", 100) + +:PICK_FROM_SEED_TROUGH: # =================================== + :args: + :locals: + :kind: scope_declaration + :args: {} + :body: + - :kind: parameter_declaration + :args: + :label: Seed Trough + :default_value: + :kind: location_placeholder + :args: {} + - :kind: variable_declaration + :args: + :label: Trough Depth + :data_value: + :kind: numeric + :args: + :number: 10 + :color: yellow + :name: Pick from Seed Trough + :body: + - :kind: lua + :args: + :lua: | + trough = variable("Seed Trough") + trough_depth = variable("Trough Depth") + seeder_tool = get_tool({name = "Seeder"}) + seeder_tip_z_offset = seeder_tool.seeder_tip_z_offset or 80 + vacuum_pump_pin = 9 + + -- Checks + if not verify_tool() then + return + end + + -- Send message with trough info + toast("Picking up seed from " .. trough.name) + + -- Job + function job(status, percent) + set_job("Pick from Seed trough", { + status = status, + percent = percent + }) + end + + -- Move to above the trough + job("Moving to seed trough", 25) + move{ + y = trough.y, + z = trough.z + seeder_tip_z_offset + 25, + grouping = "xy,z", + route = "high" + } + + -- Pick up seed + job("Picking up seed", 75) + on(vacuum_pump_pin) + move{ + z = trough.z + seeder_tip_z_offset - trough_depth, + speed = 50 + } + + -- Retract Z + job("Retracting Z", 90) + move{z=trough.z + seeder_tip_z_offset + 25} + job("Complete", 100) + +:PICK_FROM_SEED_BIN: # =================================== + :args: + :locals: + :kind: scope_declaration + :args: {} + :body: + - :kind: parameter_declaration + :args: + :label: Seed Bin + :default_value: + :kind: location_placeholder + :args: {} + - :kind: variable_declaration + :args: + :label: Bin Depth + :data_value: + :kind: numeric + :args: + :number: 20 + :color: yellow + :name: Pick from Seed Bin + :body: + - :kind: lua + :args: + :lua: | + bin = variable("Seed Bin") + bin_depth = variable("Bin Depth") + seeder_tool = get_tool({name = "Seeder"}) + seeder_tip_z_offset = seeder_tool.seeder_tip_z_offset or 80 + vacuum_pump_pin = 9 + + -- Checks + if not verify_tool() then + return + end + + -- Send message with bin info + toast("Picking up seed from " .. bin.name) + + -- Job + function job(status, percent) + set_job("Pick from Seed Bin", { + status = status, + percent = percent + }) + end + + -- Move to above the bin + job("Moving to seed bin", 25) + move{ + x = bin.x, + y = bin.y, + z = bin.z + seeder_tip_z_offset + 25, + grouping = "xy,z", + route = "high" + } + + -- Pick up seed + job("Picking up seed", 75) + on(vacuum_pump_pin) + move{ + z = bin.z + seeder_tip_z_offset - bin_depth, + speed = 50 + } + + -- Retract Z + job("Retracting Z", 90) + move{z=bin.z + seeder_tip_z_offset + 25} + job("Complete", 100) :PHOTO_GRID: # =================================== :args: @@ -828,7 +912,26 @@ :body: - :kind: lua :args: - :lua: "local grid = photo_grid()\ntype = \"Scan\"\njob = \"Photo Grid\"\nstart_time = os.time() * 1000\n\ngrid.each(function(cell)\n set_job_progress(job, {\n percent = 100 * (cell.count - 0.5) / grid.total,\n status = \"Moving\",\n time = start_time\n })\n move_absolute(cell.x, cell.y, cell.z)\n set_job_progress(job, {\n percent = 100 * (cell.count / grid.total),\n status = \"Taking photo\",\n time = start_time\n })\n local msg = \"Taking photo \" .. cell.count .. \" of \" .. grid.total\n send_message(\"info\", msg)\n take_photo()\nend)\n\nset_job_progress(job, {\n percent = 100,\n status = \"Complete\",\n time = start_time\n})" + :lua: | + local grid = photo_grid() + job = "Photo Grid" + + grid.each(function(cell) + set_job(job, { + percent = 100 * (cell.count - 0.5) / grid.total, + status = "Moving" + }) + move{x=cell.x, y=cell.y, z=cell.z} + set_job(job, { + percent = 100 * (cell.count / grid.total), + status = "Taking photo" + }) + local msg = "Taking photo " .. cell.count .. " of " .. grid.total + send_message("info", msg) + take_photo() + end) + + complete_job(job) :MOW_ALL_WEEDS: # =================================== :args: @@ -840,7 +943,120 @@ :body: - :kind: lua :args: - :lua: "rotary_tool_pin = 2 -- 3 for REV\nmax_load = tonumber(env(\"rotary_tool_max_load\")) or 90\nrotary_tool_height = tonumber(env(\"rotary_tool_height\")) or 80\nmax_attempts = tonumber(env(\"rotary_tool_max_attempts\")) or 3\nweeds = {}\ncount = 0\n\nfunction job(status, percent)\n set_job_progress(\"Mowing weed at \" .. coords, {\n status = status,\n percent = percent,\n time = job_time\n })\nend\n\npjob_time = os.time() * 1000\nfunction pjob(status, percent)\n set_job_progress(\"Mowing \" .. #weeds .. \" weeds\", {\n status = status,\n percent = percent,\n time = pjob_time\n })\nend\n\nwatcher = function(data)\n if (data.value > max_load) and (env(\"load\") ~= \"stalled\") then\n env(\"load\", \"stalled\")\n soft_stop()\n off(rotary_tool_pin)\n toast(\"Rotary tool max load exceeded (load = \" .. data.value .. \")\", \"warn\")\n end\nend\n\nfunction attempt_weeding()\n attempts = attempts + 1\n env(\"load\", \"nominal\")\n job(\"Moving to weed\", 10)\n move{\n x = weed.x - (weed.radius + 50),\n y = weed.y,\n z = weed.z + rotary_tool_height + 20,\n safe_z = true\n }\n\n on(rotary_tool_pin)\n\n if env(\"load\") == \"stalled\" then\n wait(1500)\n return\n end\n job(\"Descending\", 40)\n move{\n z = weed.z + rotary_tool_height,\n speed = 25\n }\n\n if env(\"load\") == \"stalled\" then\n wait(1500)\n return\n end\n job(\"Mowing\", 50)\n move{\n x = weed.x + (weed.radius + 50),\n speed = 25\n }\n\n if env(\"load\") == \"stalled\" then\n wait(1500)\n return\n end\n job(\"Ascending\", 90)\n move{\n z = weed.z + rotary_tool_height + 20,\n speed = 25\n }\n\n if env(\"load\") == \"stalled\" then\n wait(1500)\n return\n end\n off(rotary_tool_pin)\n success = true\nend\n\nif not verify_tool() then\n return\nend\n\npoints = api({\n method = \"GET\",\n url = \"/api/points\"\n})\n\nfor k, v in pairs(points) do\n if v.pointer_type == \"Weed\" then\n table.insert(weeds, {x = v.x, y = v.y, z = soil_height(v.x, v.y), radius = v.radius})\n end\nend\n\nwatch_pin(60, watcher)\n\nfor k, v in pairs(weeds) do\n weed = v\n count = count + 1\n job_time = os.time() * 1000\n pjob(\"Mowing weed \" .. count .. \" of \" .. #weeds, count / (#weeds + 1) * 100)\n coords = \"(\" .. weed.x .. \", \" .. weed.y .. \", \" .. weed.z .. \")\"\n attempts = 0\n success = false\n while (attempts < max_attempts) and (success == false) do\n attempt_weeding()\n end\n if env(\"load\") == \"stalled\" then\n toast(\"Mowing weed at \" .. coords .. \" failed after \" .. attempts .. \" attempt(s); proceeding...\", \"warn\")\n end\n job(\"Complete\", 100)\nend\npjob(\"Complete\", 100)\ntoast(\"Mowing complete\", \"success\")" + :lua: | + rotary_tool_pin = 2 -- 3 for REV + max_load = tonumber(env("rotary_tool_max_load")) or 90 + rotary_tool_height = tonumber(env("rotary_tool_height")) or 80 + max_attempts = tonumber(env("rotary_tool_max_attempts")) or 3 + count = 0 + + function job(status, percent) + set_job("Mowing weed at " .. coords, { + status = status, + percent = percent + }) + end + + function parent_job(status, percent) + set_job("Mowing " .. #weeds .. " weeds", { + status = status, + percent = percent + }) + end + + watcher = function(data) + if (data.value > max_load) and (env("load") ~= "stalled") then + env("load", "stalled") + soft_stop() + off(rotary_tool_pin) + toast("Rotary tool max load exceeded (load = " .. data.value .. ")", "warn") + end + end + + function attempt_weeding(weed) + attempts = attempts + 1 + env("load", "nominal") + job("Moving to weed", 10) + move{ + x = weed.x - (weed.radius + 50), + y = weed.y, + z = weed.z + rotary_tool_height + 20, + safe_z = true + } + + on(rotary_tool_pin) + + if env("load") == "stalled" then + wait(1500) + return + end + job("Descending", 40) + move{ + z = weed.z + rotary_tool_height, + speed = 25 + } + + if env("load") == "stalled" then + wait(1500) + return + end + job("Mowing", 50) + move{ + x = weed.x + (weed.radius + 50), + speed = 25 + } + + if env("load") == "stalled" then + wait(1500) + return + end + job("Ascending", 90) + move{ + z = weed.z + rotary_tool_height + 20, + speed = 25 + } + + if env("load") == "stalled" then + wait(1500) + return + end + off(rotary_tool_pin) + success = true + end + + -- Check if tool is mounted to UTM + if not verify_tool() then + return + end + + -- Get all active weeds and sort + weeds = sort(get_weeds(), "nn") + + -- Interpolate weed Z coordinate from soil height + for _, weed in pairs(weeds) do + weed.z = soil_height(weed.x, weed.y) + end + + -- Initialize load monitoring + watch_pin(60, watcher) + + -- Main weeding loop + for _, weed in pairs(weeds) do + count = count + 1 + parent_job("Mowing weed " .. count .. " of " .. #weeds, count / (#weeds + 1) * 100) + coords = "(" .. weed.x .. ", " .. weed.y .. ", " .. weed.z .. ")" + attempts = 0 + success = false + while (attempts < max_attempts) and (success == false) do + attempt_weeding(weed) + end + if env("load") == "stalled" then + toast("Mowing weed at " .. coords .. " failed after " .. attempts .. " attempt(s); proceeding...", "warn") + end + job("Complete", 100) + end + parent_job("Complete", 100) + toast("Mowing complete", "success") :GRID: # =================================== :args: @@ -848,6 +1064,15 @@ :kind: scope_declaration :args: {} :body: + - :kind: parameter_declaration + :args: + :label: Starting location + :default_value: + :kind: coordinate + :args: + :x: 0 + :y: 0 + :z: 0 - :kind: parameter_declaration :args: :label: Number of points @@ -878,7 +1103,43 @@ :body: - :kind: lua :args: - :lua: "subsequence = variable(\"Subsequence\")\nstart = variable(\"Starting location\")\nspacing = variable(\"Spacing\")\ngrid_points = variable(\"Number of points\")\ngrid_points_total = grid_points.x * grid_points.y * grid_points.z\njob = subsequence.name .. \" x\" .. grid_points_total\nindex = 0\ngrid_max_x = start.x + (spacing.x * grid_points.x)\ngrid_max_y = start.y + (spacing.y * grid_points.y)\nstart_time = os.time() * 1000\n\nfunction failure(reason) send_message(\"error\", reason, \"toast\") end\n\nif grid_points.x <= 0 or grid_points.y <= 0 or grid_points.z <= 0 then\n failure(\"Number of points must be greater than 0 for all three axes\")\n return\nelseif grid_max_x > garden_size().x or grid_max_y > garden_size().y then\n failure(\"Grid must not exceed the **AXIS LENGTH** for the X or Y axes\")\n return\nend\n\nmsg = \"Executing `\" .. subsequence.name .. \"` \" .. grid_points_total ..\n \" times in a \" .. grid_points.x .. \" x \" .. grid_points.y ..\n \" x \" .. grid_points.z .. \" grid\"\nsend_message(\"info\", msg, \"toast\")\n\n-- Move to each grid point and execute the subsequence\n\nfor grid_index_z = 0, (grid_points.z - 1) do\n z = start.z + (spacing.z * grid_index_z)\n for grid_index_x = 0, (grid_points.x - 1) do\n x = start.x + (spacing.x * grid_index_x)\n for grid_index_y = 0, (grid_points.y - 1) do\n if (grid_index_x % 2) == 0 then\n y = start.y + (spacing.y * grid_index_y)\n else\n reversed_index_y = grid_points.y - 1 - grid_index_y\n y = start.y + (spacing.y * reversed_index_y)\n end\n set_job_progress(job, {\n percent = 100 * index / grid_points_total,\n status = \"Moving\",\n time = start_time\n })\n move_absolute(x, y, z)\n set_job_progress(job, {\n percent = 100 * (index + 0.5) / grid_points_total,\n status = \"Executing subsequence\",\n time = start_time\n })\n cs_eval(subsequence)\n index = index + 1\n end\n end\nend\n\nset_job_progress(job, {\n percent = 100,\n status = \"Complete\",\n time = start_time\n})" + :lua: | + subsequence = variable("Subsequence") + grid_points = variable("Number of points") + + local grid = grid{ + grid_points = grid_points, + start = variable("Starting location"), + spacing = variable("Spacing") + } + + job = subsequence.name .. " x" .. grid.total + + toast("Executing `" .. subsequence.name .. "` " .. grid.total .. + " times in a " .. grid_points.x .. " x " .. grid_points.y .. + " x " .. grid_points.z .. " grid") + + -- Loop through the grid points + grid.each(function(cell) + -- Move + local grid_point_coordinates = "(" .. cell.x .. ", " .. cell.y .. ", " .. cell.z .. ")" + set_job(job, { + percent = 100 * (cell.count - 0.5) / grid.total, + status = "Moving to " .. grid_point_coordinates + }) + send_message("info", "Moving to grid point " .. cell.count .. " at " .. grid_point_coordinates) + move{x=cell.x, y=cell.y, z=cell.z} + + -- Execute subsequence + set_job(job, { + percent = 100 * (cell.count / grid.total), + status = "Executing subsequence" + }) + send_message("info", "Executing subsequence `" .. subsequence.name .. "`") + cs_eval(subsequence) + end) + + complete_job(job) :DISPENSE_WATER: # =================================== :args: @@ -898,7 +1159,8 @@ :body: - :kind: lua :args: - :lua: "-- Get all tools\ntools = api({ url = \"/api/tools/\" })\nif not tools then\n toast(\"API error\", \"error\")\n return\nend\n\n-- Pluck the Watering Nozzle tool\nfor key, tool in pairs(tools) do\n if tool.name == \"Watering Nozzle\" then\n watering_nozzle = tool\n wfr = watering_nozzle.flow_rate_ml_per_s\n end\nend\n\nml = variable(\"Water (mL)\")\n\nif not wfr then\n toast('You must have a tool named \"Watering Nozzle\" to use this sequence.', 'error')\n return\nelseif wfr == 0 then\n toast(\"**WATER FLOW RATE (mL/s)** must be greater than 0 for the Watering Nozzle tool. Refer to the sequence description for setup instructions.\", \"error\")\n return\nelseif ml <= 0 then\n toast(\"Water (mL) must be greater than 0\", \"error\")\n return\nelseif ml > 10000 then\n toast(\"Water (mL) cannot be more than 10,000\", \"error\")\n return\nend\n\nseconds = math.floor(ml / wfr * 100) / 100\ntoast(\"Dispensing \" .. ml .. \"mL of water over \" .. seconds .. \" seconds\")\non(8)\nwait(seconds * 1000)\noff(8)" + :lua: | + dispense(variable("Water (mL)")) :WEED_DETECTION_GRID: # =================================== :args: @@ -910,4 +1172,24 @@ :body: - :kind: lua :args: - :lua: "local grid = photo_grid()\ntype = \"Scan\"\njob = \"Weed Detection Grid\"\nstart_time = os.time() * 1000\n\ngrid.each(function(cell)\n set_job_progress(job, {\n percent = 100 * (cell.count - 0.5) / grid.total,\n status = \"Moving\",\n time = start_time\n })\n move_absolute(cell.x, cell.y, cell.z)\n set_job_progress(job, {\n percent = 100 * (cell.count / grid.total),\n status = \"Detecting weeds\",\n time = start_time\n })\n local msg = \"Detecting weeds at location \" .. cell.count .. \" of \" ..\n grid.total\n send_message(\"info\", msg)\n detect_weeds()\nend)\n\nset_job_progress(job, {\n percent = 100,\n status = \"Complete\",\n time = start_time\n})" + :lua: | + local grid = photo_grid() + job = "Weed Detection Grid" + + grid.each(function(cell) + set_job(job, { + percent = 100 * (cell.count - 0.5) / grid.total, + status = "Moving" + }) + move{x=cell.x, y=cell.y, z=cell.z} + set_job(job, { + percent = 100 * (cell.count / grid.total), + status = "Detecting weeds" + }) + local msg = "Detecting weeds at location " .. cell.count .. " of " .. + grid.total + send_message("info", msg) + detect_weeds() + end) + + complete_job(job) diff --git a/app/mutations/sequences/publish.rb b/app/mutations/sequences/publish.rb index f1bdcd71d7..9bd104f69c 100644 --- a/app/mutations/sequences/publish.rb +++ b/app/mutations/sequences/publish.rb @@ -1,11 +1,11 @@ module Sequences class Publish < Mutations::Command NOT_YOURS = "Can't publish sequences you didn't create." - OK_KINDS = %w( axis axis_addition axis_overwrite calibrate channel + OK_KINDS = %w( axis axis_addition axis_order axis_overwrite calibrate channel channel_name coordinate emergency_lock execute execute_script find_home identifier is_outdated label location_placeholder lua message message_type milliseconds move move_absolute - move_relative nothing number number_placeholder numeric + move_relative nothing number number_placeholder numeric order op package pair parameter_application parameter_declaration pin_mode pin_number pin_type pin_value point pointer_type power_off random read_pin reboot resource_placeholder diff --git a/app/mutations/tools/create.rb b/app/mutations/tools/create.rb index 78e8e8beb4..b1014aa5a5 100644 --- a/app/mutations/tools/create.rb +++ b/app/mutations/tools/create.rb @@ -7,6 +7,7 @@ class Create < Mutations::Command optional do integer :flow_rate_ml_per_s + float :seeder_tip_z_offset end def execute diff --git a/app/mutations/tools/update.rb b/app/mutations/tools/update.rb index 0a2284abce..51a25d89b1 100644 --- a/app/mutations/tools/update.rb +++ b/app/mutations/tools/update.rb @@ -7,6 +7,7 @@ class Update < Mutations::Command optional do string :name integer :flow_rate_ml_per_s + float :seeder_tip_z_offset end def execute diff --git a/app/serializers/tool_serializer.rb b/app/serializers/tool_serializer.rb index 15db0a1657..0c2425845b 100644 --- a/app/serializers/tool_serializer.rb +++ b/app/serializers/tool_serializer.rb @@ -1,5 +1,5 @@ class ToolSerializer < ApplicationSerializer - attributes :name, :status, :flow_rate_ml_per_s + attributes :name, :status, :flow_rate_ml_per_s, :seeder_tip_z_offset def status # The attribute `tool_slot_id` is added via a special SQL query. diff --git a/app/views/dashboard/demo.html.erb b/app/views/dashboard/demo.html.erb index 0da75730f7..216bad188c 100644 --- a/app/views/dashboard/demo.html.erb +++ b/app/views/dashboard/demo.html.erb @@ -42,11 +42,11 @@ font-size: 25px; font-weight: bold; padding: 15px 30px; - position: absolute; + position: fixed; } .demo-options { - position: absolute; + position: fixed; bottom: 15px; } diff --git a/db/migrate/20250722234106_add_seeder_tip_z_offset.rb b/db/migrate/20250722234106_add_seeder_tip_z_offset.rb new file mode 100644 index 0000000000..6279b83ebf --- /dev/null +++ b/db/migrate/20250722234106_add_seeder_tip_z_offset.rb @@ -0,0 +1,9 @@ +class AddSeederTipZOffset < ActiveRecord::Migration[6.1] + def up + add_column :tools, :seeder_tip_z_offset, :float, default: 80 + end + + def down + remove_column :tools, :seeder_tip_z_offset + end +end diff --git a/db/migrate/20250802174543_add_default_axis_order_to_fbos_config.rb b/db/migrate/20250802174543_add_default_axis_order_to_fbos_config.rb new file mode 100644 index 0000000000..5cc9d5461e --- /dev/null +++ b/db/migrate/20250802174543_add_default_axis_order_to_fbos_config.rb @@ -0,0 +1,9 @@ +class AddDefaultAxisOrderToFbosConfig < ActiveRecord::Migration[6.1] + def up + add_column :fbos_configs, :default_axis_order, :string, default: "xy,z;high", limit: 10 + end + + def down + remove_column :fbos_configs, :default_axis_order + end +end diff --git a/db/structure.sql b/db/structure.sql index 948a6db753..9a974c3f62 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -577,7 +577,8 @@ CREATE TABLE public.fbos_configs ( boot_sequence_id integer, safe_height integer DEFAULT 0, soil_height integer DEFAULT 0, - gantry_height integer DEFAULT 0 + gantry_height integer DEFAULT 0, + default_axis_order character varying(10) DEFAULT 'xy,z;high'::character varying ); @@ -1003,7 +1004,8 @@ CREATE TABLE public.tools ( created_at timestamp without time zone NOT NULL, updated_at timestamp without time zone NOT NULL, device_id integer, - flow_rate_ml_per_s integer DEFAULT 0 + flow_rate_ml_per_s integer DEFAULT 0, + seeder_tip_z_offset double precision DEFAULT 80.0 ); @@ -3983,6 +3985,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241203211516'), ('20250221191831'), ('20250502201109'), -('20250514203443'); +('20250514203443'), +('20250722234106'), +('20250802174543'); diff --git a/docker-compose.yml b/docker-compose.yml index 2f2344f564..0fa93825b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: db: env_file: ".env" - image: postgres:16 + image: postgres:17 restart: always volumes: ["./docker_volumes/db:/var/lib/postgresql/data"] diff --git a/docker_configs/api.Dockerfile b/docker_configs/api.Dockerfile index f5496a05c6..b32180adff 100644 --- a/docker_configs/api.Dockerfile +++ b/docker_configs/api.Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.4.4 +FROM ruby:3.4.5 RUN curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/trusted.gpg.d/apt.postgresql.org.gpg > /dev/null && \ sh -c '. /etc/os-release; echo $VERSION_CODENAME; echo "deb http://apt.postgresql.org/pub/repos/apt/ $VERSION_CODENAME-pgdg main" >> /etc/apt/sources.list.d/pgdg.list' && \ apt-get update -qq && apt-get install -y build-essential libpq-dev postgresql postgresql-contrib && \ diff --git a/frontend/__test_support__/fake_state/app.ts b/frontend/__test_support__/fake_state/app.ts index 93249750d4..0f4bf5251f 100644 --- a/frontend/__test_support__/fake_state/app.ts +++ b/frontend/__test_support__/fake_state/app.ts @@ -27,5 +27,4 @@ export const app: AppState = { controls: controlsState(), jobs: jobsState(), popups: popUpsState(), - hotkeyGuide: false, }; diff --git a/frontend/__test_support__/fake_state/bot.ts b/frontend/__test_support__/fake_state/bot.ts index ba00d19a55..32c6e9eb90 100644 --- a/frontend/__test_support__/fake_state/bot.ts +++ b/frontend/__test_support__/fake_state/bot.ts @@ -67,4 +67,5 @@ export const bot: Everything["bot"] = { }, needVersionCheck: true, alreadyToldUserAboutMalformedMsg: false, + demoQueueLength: 0, }; diff --git a/frontend/__test_support__/fake_state/resources.ts b/frontend/__test_support__/fake_state/resources.ts index 5d23a96b92..1f80dad22c 100644 --- a/frontend/__test_support__/fake_state/resources.ts +++ b/frontend/__test_support__/fake_state/resources.ts @@ -125,6 +125,7 @@ export function fakeTool(): TaggedTool { return fakeResource("Tool", { name: "Foo", flow_rate_ml_per_s: 0, + seeder_tip_z_offset: 80, }); } @@ -312,7 +313,8 @@ export function fakeFbosConfig(): TaggedFbosConfig { sequence_init_log: false, firmware_hardware: "arduino", os_auto_update: false, - arduino_debug_messages: false + arduino_debug_messages: false, + default_axis_order: "xyz;high", }); } diff --git a/frontend/__test_support__/resource_index_builder.ts b/frontend/__test_support__/resource_index_builder.ts index 28e54d98b2..9e456bce1c 100644 --- a/frontend/__test_support__/resource_index_builder.ts +++ b/frontend/__test_support__/resource_index_builder.ts @@ -314,6 +314,7 @@ const tr14: TaggedResource = { "id": 14, "name": "Trench Digging Tool", "flow_rate_ml_per_s": 0, + "seeder_tip_z_offset": 80, }, "uuid": "Tool.14.49" }; @@ -325,6 +326,7 @@ const tr15: TaggedResource = { "id": 15, "name": "Berry Picking Tool", "flow_rate_ml_per_s": 0, + "seeder_tip_z_offset": 80, }, "uuid": "Tool.15.50" }; diff --git a/frontend/__test_support__/setup_enzyme.ts b/frontend/__test_support__/setup_enzyme.ts index 59798bedaf..babb483049 100644 --- a/frontend/__test_support__/setup_enzyme.ts +++ b/frontend/__test_support__/setup_enzyme.ts @@ -1,5 +1,6 @@ import { TextEncoder } from "util"; -global.TextEncoder = TextEncoder; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +global.TextEncoder = TextEncoder as any; import Enzyme from "enzyme"; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index fe9abda588..5fe6a4432c 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -430,11 +430,11 @@ jest.mock("@react-three/drei", () => { nodes: { [PartName.utm]: {} as THREE.Mesh }, materials: { PaletteMaterial001: {} as THREE.MeshStandardMaterial }, }, - [ASSETS.models.ccHorizontal]: { - nodes: { [PartName.ccHorizontal]: {} as THREE.Mesh }, + [ASSETS.models.ccSupportHorizontal]: { + nodes: { [PartName.ccSupportHorizontal]: {} as THREE.Mesh }, }, - [ASSETS.models.ccVertical]: { - nodes: { [PartName.ccVertical]: {} as THREE.Mesh }, + [ASSETS.models.ccSupportVertical]: { + nodes: { [PartName.ccSupportVertical]: {} as THREE.Mesh }, }, [ASSETS.models.housingVertical]: { nodes: { [PartName.housingVertical]: {} as THREE.Mesh }, diff --git a/frontend/__tests__/hotkeys_test.tsx b/frontend/__tests__/hotkeys_test.tsx index 1794c6f00d..af786860d6 100644 --- a/frontend/__tests__/hotkeys_test.tsx +++ b/frontend/__tests__/hotkeys_test.tsx @@ -91,18 +91,17 @@ describe("hotkeysWithActions()", () => { describe("toggleHotkeyHelpOverlay()", () => { it("opens overlay", () => { - const dispatch = jest.fn(); - toggleHotkeyHelpOverlay(dispatch)(); - expect(dispatch).toHaveBeenCalledWith({ - type: Actions.TOGGLE_HOTKEY_GUIDE, payload: undefined, - }); + document.dispatchEvent = jest.fn(); + toggleHotkeyHelpOverlay(); + expect(document.dispatchEvent).toHaveBeenCalledWith( + new KeyboardEvent("keydown", { key: "?", shiftKey: true, bubbles: true }), + ); }); }); describe("", () => { const fakeProps = (): HotKeysProps => ({ dispatch: jest.fn(), - hotkeyGuide: false, designer: fakeDesignerState(), }); diff --git a/frontend/__tests__/reducer_test.ts b/frontend/__tests__/reducer_test.ts index 4e8b1ee32b..2524d59510 100644 --- a/frontend/__tests__/reducer_test.ts +++ b/frontend/__tests__/reducer_test.ts @@ -179,16 +179,6 @@ describe("resource reducer", () => { expect(newState.popups.connectivity).toBeFalsy(); }); - it("toggle hotkey guide", () => { - const state = app; - expect(state.hotkeyGuide).toBeFalsy(); - const newState = appReducer(state, { - type: Actions.TOGGLE_HOTKEY_GUIDE, - payload: undefined, - }); - expect(newState.hotkeyGuide).toBeTruthy(); - }); - it("adds toast", () => { const newState = appReducer(app, { type: Actions.CREATE_TOAST, diff --git a/frontend/app.tsx b/frontend/app.tsx index a4211f8f45..26b9a26477 100644 --- a/frontend/app.tsx +++ b/frontend/app.tsx @@ -185,9 +185,7 @@ export class RawApp extends React.Component { {(Path.equals("") || Path.equals(Path.app())) && isString(landingPage) && } {!syncLoaded && } - + {syncLoaded && { const { axis, val, missedSteps, axisState, index, detectionEnabled } = props; - return
+ return
{isNumber(missedSteps) && missedSteps >= 0 && detectionEnabled && Promise; + onCommit: (v: Vector3) => Promise | undefined; position: BotPosition; disabled: boolean | undefined; locked: boolean; diff --git a/frontend/controls/move/__tests__/missed_step_indicator_test.tsx b/frontend/controls/move/__tests__/missed_step_indicator_test.tsx index d4d61ea271..0803afdd19 100644 --- a/frontend/controls/move/__tests__/missed_step_indicator_test.tsx +++ b/frontend/controls/move/__tests__/missed_step_indicator_test.tsx @@ -78,7 +78,7 @@ describe("", () => { p.missedSteps = missedSteps; const wrapper = mount(); wrapper.setState({ history }); - wrapper.find(".bp5-popover-target").simulate("click"); + wrapper.find(".bp6-popover-target").simulate("click"); ["motor load", latest, max, average].map(string => expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase())); }); diff --git a/frontend/controls/move/__tests__/take_photo_button_test.tsx b/frontend/controls/move/__tests__/take_photo_button_test.tsx index b48ab55c85..962cca4587 100644 --- a/frontend/controls/move/__tests__/take_photo_button_test.tsx +++ b/frontend/controls/move/__tests__/take_photo_button_test.tsx @@ -53,7 +53,7 @@ describe("", () => { const p = fakeProps(); p.botOnline = false; const jogButtons = mount(); - expect(jogButtons.html()).toContain("bp5-popover-target"); + expect(jogButtons.html()).toContain("bp6-popover-target"); }); it("shows as taken", () => { diff --git a/frontend/controls/peripherals/peripheral_list.tsx b/frontend/controls/peripherals/peripheral_list.tsx index 54e02ae541..7d3e1c0114 100644 --- a/frontend/controls/peripherals/peripheral_list.tsx +++ b/frontend/controls/peripherals/peripheral_list.tsx @@ -29,7 +29,7 @@ export const PeripheralList = (props: PeripheralListProps) => : { - peripheral.body.pin && props.dispatch(pinToggle(peripheral.body.pin)); + peripheral.body.pin && pinToggle(peripheral.body.pin); }} title={t(`Toggle ${peripheral.body.label}`)} customText={{ textFalse: t("off"), textTrue: t("on") }} diff --git a/frontend/css/_blueprint_overrides.scss b/frontend/css/_blueprint_overrides.scss index a11678493e..c3cbf07e3f 100644 --- a/frontend/css/_blueprint_overrides.scss +++ b/frontend/css/_blueprint_overrides.scss @@ -2,24 +2,24 @@ @use "sass:color"; // Padding for the popups. -.bp5-popover-content { +.bp6-popover-content { z-index: 999; padding: 1rem; color: var(--text-color) !important; background: unset !important; } -.bp5-popover { +.bp6-popover { font-size: 1.3rem; border-radius: 0.5rem; overflow: hidden; background: unset !important; - .bp5-popover-arrow { + .bp6-popover-arrow { display: none !important; } } -.bp5-popover-transition-container { +.bp6-popover-transition-container { backdrop-filter: var(--blur); border-radius: 0.75rem; overflow: hidden; @@ -28,55 +28,55 @@ } // Arrow is slightly off by default in the popup menu. -.bp5-tether-element-attached-left.bp5-tether-target-attached-right>.bp5-popover>.bp5-popover-arrow { +.bp6-tether-element-attached-left.bp6-tether-target-attached-right>.bp6-popover>.bp6-popover-arrow { left: -1rem; } -.bp5-overlay-content.bp5-tether-abutted.bp5-tether-abutted-top.bp5-tether-element.bp5-tether-element-attached-left.bp5-tether-element-attached-top.bp5-tether-enabled.bp5-tether-target-attached-bottom.bp5-tether-target-attached-left.bp5-transition-container { +.bp6-overlay-content.bp6-tether-abutted.bp6-tether-abutted-top.bp6-tether-element.bp6-tether-element-attached-left.bp6-tether-element-attached-top.bp6-tether-enabled.bp6-tether-target-attached-bottom.bp6-tether-target-attached-left.bp6-transition-container { top: 2rem !important; } -.bp5-portal { +.bp6-portal { z-index: 99; } -.bp5-menu { +.bp6-menu { max-height: 20rem !important; overflow-y: scroll; } -.bp5-input { +.bp6-input { box-shadow: none; border-radius: 0; } -.bp5-input-group { +.bp6-input-group { border-bottom: 1.5px solid var(--border-color); - .bp5-icon { + .bp6-icon { top: 0.6rem; } } -.bp5-popover.help { - .bp5-popover-content { +.bp6-popover.help { + .bp6-popover-content { width: 32rem; } } -.bp5-slider { +.bp6-slider { margin: 0 2.5rem 0 1.5rem; width: initial; } -.bp5-slider-progress { +.bp6-slider-progress { background: var(--secondary-bg); } -.bp5-control { +.bp6-control { margin: 0; } -.bp5-button:not(.bp5-minimal) { +.bp6-button:not(.bp6-minimal) { font-weight: normal; text-transform: none; border-radius: 3px; @@ -100,14 +100,14 @@ } } -.bp5-icon-standard.bp5-align-right { +.bp6-icon-standard.bp6-align-right { float: right !important; margin-top: 0.6rem !important; margin-left: 0 !important; font-size: 1.6rem !important; } -.nav-right .bp5-popover-wrapper { +.nav-right .bp6-popover-wrapper { transition: all 0.2s ease; text-transform: uppercase; color: $gray; @@ -120,18 +120,33 @@ } } -.bp5-collapse { +.bp6-collapse { scrollbar-width: none; } -.bp5-collapse::-webkit-scrollbar, -.bp5-collapse-body::-webkit-scrollbar { +.bp6-collapse::-webkit-scrollbar, +.bp6-collapse-body::-webkit-scrollbar { display: none !important; width: 0px !important; background-color: transparent !important; } -*[class*=bp5-] { +.bp6-hotkey-dialog { + background: var(--main-bg) !important; + .bp6-heading { + display: none; + } + .bp6-hotkey-label { + color: var(--text-color) !important; + font-size: 1.4rem !important; + } + .bp6-key { + background: var(--secondary-bg) !important; + color: var(--text-color) !important; + } +} + +*[class*=bp6-] { &:focus { outline: none; } diff --git a/frontend/css/app/navbar.scss b/frontend/css/app/navbar.scss index a921e79656..d041fc42d0 100644 --- a/frontend/css/app/navbar.scss +++ b/frontend/css/app/navbar.scss @@ -221,6 +221,7 @@ nav { position: relative; .nav-job-info { display: inline; + max-width: 15rem; } .fa-history, .nav-job-info { @@ -228,6 +229,9 @@ nav { z-index: 1; margin-right: 0; } + .title { + max-width: 10rem; + } .jobs-button-progress-text, .title { display: inline-block; @@ -270,7 +274,7 @@ nav { } .menu-popover { - .bp5-popover-content { + .bp6-popover-content { position: relative; width: 22rem; padding: 0; @@ -312,7 +316,7 @@ nav { } } } - .bp5-popover-arrow { + .bp6-popover-arrow { visibility: hidden; } .fa-user { @@ -328,7 +332,7 @@ nav { body:has(.app.dark) { .menu-popover { - .bp5-popover-content { + .bp6-popover-content { img { filter: invert(0.75); } diff --git a/frontend/css/app/static_pages.scss b/frontend/css/app/static_pages.scss index 6896423708..75e438de97 100644 --- a/frontend/css/app/static_pages.scss +++ b/frontend/css/app/static_pages.scss @@ -35,6 +35,11 @@ .all-content-wrapper { max-width: 60rem; } + @media screen and (max-width: 1075px) { + .all-content-wrapper { + overflow: scroll; + } + } .widget-wrapper { box-shadow: 0 0 20px rgba(0, 0, 0, 0.2); margin: 0 auto; @@ -113,7 +118,6 @@ } } -.featured-sequences-page, .os-download-page { text-align: center; .all-content-wrapper { @@ -330,33 +334,3 @@ } } } - -.featured-sequences-page { - summary { - color: $light_gray; - cursor: pointer; - } - .sequence-description { - margin-left: -1.5rem; - margin-right: -5rem; - } - .markdown { - p { - color: $white !important; - } - } - details { - max-width: calc(min(70vw, 370px)); - } - summary { - margin-bottom: 0.5rem; - } - h1, - h2 { - margin-bottom: 0; - } - li, - p { - white-space: pre-wrap; - } -} diff --git a/frontend/css/app/status_ticker.scss b/frontend/css/app/status_ticker.scss index 3efcd37411..3971dbd4bf 100644 --- a/frontend/css/app/status_ticker.scss +++ b/frontend/css/app/status_ticker.scss @@ -14,7 +14,7 @@ label { cursor: pointer; } - .bp5-collapse { + .bp6-collapse { width: 100% !important; overflow-y: scroll !important; max-height: 20rem !important; diff --git a/frontend/css/components/go_button.scss b/frontend/css/components/go_button.scss index 4a65e02a07..7942ca3213 100644 --- a/frontend/css/components/go_button.scss +++ b/frontend/css/components/go_button.scss @@ -30,7 +30,7 @@ } .go-button-axes-popover { - .bp5-popover-content { + .bp6-popover-content { color: $off_white; background: $dark_gray; .go-axes { diff --git a/frontend/css/components/image_flipper.scss b/frontend/css/components/image_flipper.scss index 6a9684e4ae..7de5fb42d9 100644 --- a/frontend/css/components/image_flipper.scss +++ b/frontend/css/components/image_flipper.scss @@ -7,7 +7,7 @@ overflow: hidden; } -.bp5-overlay { +.bp6-overlay { .image-flipper { z-index: 20; width: 100%; @@ -146,7 +146,7 @@ body:has(.app.darl) { font-weight: bold; font-size: 1.3rem; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; } } @@ -158,7 +158,7 @@ body:has(.app.darl) { } .image-show-menu-popover { - .bp5-popover-content { + .bp6-popover-content { padding: 0; } } @@ -313,7 +313,7 @@ body:has(.app.darl) { } } } - .bp5-slider { + .bp6-slider { margin-left: 2rem; width: 90%; } diff --git a/frontend/css/components/widgets.scss b/frontend/css/components/widgets.scss index 10f4bbfa1e..44d6f40a10 100644 --- a/frontend/css/components/widgets.scss +++ b/frontend/css/components/widgets.scss @@ -96,7 +96,7 @@ margin: 0; text-transform: uppercase; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; } } diff --git a/frontend/css/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss index 89b4b8f0ae..cd3f8bd42b 100644 --- a/frontend/css/farm_designer/farm_designer.scss +++ b/frontend/css/farm_designer/farm_designer.scss @@ -580,13 +580,13 @@ } .sliders { height: 98%; - .bp5-slider { + .bp6-slider { height: 100%; margin-left: 1rem; } .input-slider { pointer-events: none; - .bp5-slider-handle { + .bp6-slider-handle { left: 3.8px; height: 8px; width: 9px; @@ -594,20 +594,20 @@ box-shadow: none; border: 1px solid $dark_gray; border-radius: 2px; - .bp5-slider-label { + .bp6-slider-label { margin-left: -6px; margin-top: -14px; } } } .data-slider { - .bp5-start { + .bp6-start { height: 5px; &:first-of-type { display: unset; } } - .bp5-slider-label { + .bp6-slider-label { margin-left: -6rem; opacity: 1; } diff --git a/frontend/css/farm_designer/farm_designer_panels.scss b/frontend/css/farm_designer/farm_designer_panels.scss index 0685929ba8..7999514caf 100644 --- a/frontend/css/farm_designer/farm_designer_panels.scss +++ b/frontend/css/farm_designer/farm_designer_panels.scss @@ -328,7 +328,7 @@ } .dark-portal { - .bp5-popover-content { + .bp6-popover-content { background: $dark_gray; color: $off_white; } @@ -394,7 +394,7 @@ .grid-and-row-planting { position: relative; - .bp5-collapse-body { + .bp6-collapse-body { display: grid; gap: 1rem; } @@ -437,7 +437,7 @@ .move-to-form { display: grid; gap: 1rem; - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 0.5rem; } @@ -526,7 +526,7 @@ font-weight: bold; } } - .bp5-popover-wrapper { + .bp6-popover-wrapper { float: right; } .criteria-checkbox-list { @@ -612,14 +612,14 @@ } } .advanced { - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; float: none; margin-left: 1rem; font-size: 1.4rem; } .filter-search { - .bp5-popover-wrapper { + .bp6-popover-wrapper { margin-left: 0; } } diff --git a/frontend/css/global/buttons.scss b/frontend/css/global/buttons.scss index 8134154e36..d4fbccd62f 100644 --- a/frontend/css/global/buttons.scss +++ b/frontend/css/global/buttons.scss @@ -277,10 +277,13 @@ &:hover { background: rgba(0, 0, 0, 0.08); } - &:active:not(:disabled) { - box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2); + &:active:not(:disabled), &.active:not(:disabled) { + box-shadow: inset 0 0 5px var(--border-color); filter: brightness(0.95); } + &:active:not(:disabled):not(.invert), &.active:not(:disabled):not(.invert) { + box-shadow: inset 0 0 5px #0003; + } &:disabled { filter: invert(0.5); cursor: not-allowed; @@ -295,15 +298,8 @@ justify-content: right; .fa-code { font-weight: bold; - color: $gray; - &.enabled { - color: $dark_gray; - } } .inactive { - color: $gray; - } - .fa-eye-slash { - color: $gray; + color: var(--secondary-bg); } } diff --git a/frontend/css/global/colors.scss b/frontend/css/global/colors.scss index 3dfa005fe0..3d43a20c9a 100644 --- a/frontend/css/global/colors.scss +++ b/frontend/css/global/colors.scss @@ -88,6 +88,7 @@ body:has(.app.light) { --text-color: #{$dark_gray}; --border-color: #{$translucent2}; --box-shadow: inset 0 0 5px 1px rgba(255, 255, 255, 0.25), inset 0 0 2px 1px rgba(255, 255, 255, 0.25), 0 0 5px 2px rgba(0, 0, 0, 0.05); + --highlight: inset 0px 0px 5px 5px #{$yellow}; } body:has(.app.dark) { @@ -96,6 +97,7 @@ body:has(.app.dark) { --text-color: #{$gray}; --border-color: #{$translucent3_white}; --box-shadow: inset 0 0 5px 1px #{$translucent1_white}, inset 0 0 2px 1px #{$translucent2_white}, 0 0 5px 2px #{$translucent1}; + --highlight: inset 0px 0px 5px 5px #{$yellow}; } .dark-gray { diff --git a/frontend/css/global/global.scss b/frontend/css/global/global.scss index 70eda9db00..cec8aabc53 100644 --- a/frontend/css/global/global.scss +++ b/frontend/css/global/global.scss @@ -8,7 +8,7 @@ body { .panel-container::before, .menu-content::before, -.bp5-popover-transition-container::before, +.bp6-popover-transition-container::before, .ticker-list::before { content: ""; position: absolute; @@ -22,7 +22,7 @@ body { } .app, -.bp5-portal { +.bp6-portal { background: var(--main-bg); color: var(--text-color); } @@ -73,23 +73,19 @@ body { .tabs { border-radius: 0.5rem; display: grid; - gap: 0.2rem; grid-auto-flow: column; justify-content: center; margin: 0 auto; overflow: hidden; + box-shadow: var(--box-shadow); label { padding: 0.5rem 3rem; - background: $light_gray; - color: $medium_gray; cursor: pointer; &.selected { - background: $medium_gray; - color: $off_white; + border-bottom: 3px solid var(--text-color); } &:hover { - background-color: color.adjust($white, $lightness: -40%); - color: $off_white; + background-color: var(--secondary-bg); } } } @@ -119,19 +115,17 @@ a { .drag-drop-area { &.visible { - margin: 0.75rem 0; - margin-right: 25px; - margin-left: 10px; + background: var(--secondary-bg); + border-color: var(--border-color); + border-radius: 0.5rem; border-style: dashed; border-width: 2px; - border-color: $light_gray; - color: $gray; + color: var(--text-color); + font-weight: bold; font-weight: bold; + margin: 1rem 0; padding: 1.25rem; - background: $off_white; text-align: center; - color: $gray; - font-weight: bold; } } @@ -371,8 +365,8 @@ a { position: relative; margin-right: 1rem; } - .bp5-popover-wrapper, - .bp5-popover-target { + .bp6-popover-wrapper, + .bp6-popover-target { margin-left: 1rem; } } @@ -398,7 +392,7 @@ a { button { float: none !important; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 0.5rem; font-size: 1.3rem; @@ -441,7 +435,7 @@ textarea:focus { } .beacon-transition { - transition: border 1s, border-radius 1s; + transition: border 1s, border-radius 1s, box-shadow 1s; } .beacon { @@ -449,7 +443,7 @@ textarea:focus { box-shadow: 0 0 0 4px $yellow !important; } &.soft { - background: #ffe53e52 !important; + box-shadow: var(--highlight) !important; } } diff --git a/frontend/css/global/inputs.scss b/frontend/css/global/inputs.scss index 297f13a72f..83bbdfb799 100644 --- a/frontend/css/global/inputs.scss +++ b/frontend/css/global/inputs.scss @@ -88,17 +88,17 @@ input[type="checkbox"] { .input { position: relative; - .bp5-popover-wrapper { + .bp6-popover-wrapper { position: unset; } } .input-error-wrapper { height: 0; - .bp5-popover-content { + .bp6-popover-content { min-width: 120px; } - .bp5-overlay { + .bp6-overlay { display: inline-block; } } @@ -139,7 +139,7 @@ select { width: 100%; color: $dark_gray; } - .bp5-popover-target { + .bp6-popover-target { position: relative; } i { @@ -174,19 +174,19 @@ select { } .filter-search-popover { - .bp5-popover-content { + .bp6-popover-content { max-width: 40rem; padding: 0; - .bp5-menu { + .bp6-menu { padding-left: 0; padding-right: 0; } - .bp5-input { + .bp6-input { height: auto !important; } } &.few-items { - .bp5-input-group { + .bp6-input-group { display: none; } } @@ -195,7 +195,7 @@ select { .filter-search-item { padding-left: 1rem; font-weight: normal !important; - .bp5-text-overflow-ellipsis { + .bp6-text-overflow-ellipsis { white-space: normal; padding-left: 1rem; text-indent: -1rem; @@ -310,9 +310,9 @@ fieldset { .modified { box-shadow: 0 0 0px 3px $yellow !important; border-radius: 3px; - &.bp5-slider { + &.bp6-slider { box-shadow: none !important; - .bp5-slider-handle { + .bp6-slider-handle { box-shadow: 0 0 0px 3px $yellow; } } diff --git a/frontend/css/global/saucers.scss b/frontend/css/global/saucers.scss index 9d5c75b714..80f26304dc 100644 --- a/frontend/css/global/saucers.scss +++ b/frontend/css/global/saucers.scss @@ -53,14 +53,14 @@ .colorpicker-menu { padding: 0; - .bp5-popover-arrow-fill { + .bp6-popover-arrow-fill { fill: $dark_gray; } - .bp5-popover-content { + .bp6-popover-content { width: 13rem; background: $dark_gray; } - .bp5-popover-content, + .bp6-popover-content, .color-picker-cluster, .color-picker-item-wrapper, .saucer { diff --git a/frontend/css/global/sliders.scss b/frontend/css/global/sliders.scss index ef0980b951..fff237090e 100644 --- a/frontend/css/global/sliders.scss +++ b/frontend/css/global/sliders.scss @@ -7,17 +7,17 @@ width: 100%; margin-bottom: 3rem; margin-top: 1rem; - .bp5-slider { + .bp6-slider { margin-left: 3rem; margin-top: 1rem; width: 80%; } &.vertical { - .bp5-slider { + .bp6-slider { margin-top: 0; } } - .bp5-slider-label { + .bp6-slider-label { white-space: nowrap; text-align: center; &:empty { @@ -26,14 +26,14 @@ } .data-slider { pointer-events: none; - .bp5-slider-axis, - .bp5-slider-track { + .bp6-slider-axis, + .bp6-slider-track { display: none; } - .bp5-slider-label { + .bp6-slider-label { box-shadow: none; } - .bp5-start { + .bp6-start { top: 0.53rem; width: 0.5px; background: color.adjust($dark_gray, $alpha: -0.75); @@ -45,7 +45,7 @@ } } &.vertical { - .bp5-start { + .bp6-start { top: unset; left: 0.53rem; height: 0.5px; diff --git a/frontend/css/global/tooltips.scss b/frontend/css/global/tooltips.scss index 31f97e811d..a2e16fcb16 100644 --- a/frontend/css/global/tooltips.scss +++ b/frontend/css/global/tooltips.scss @@ -28,7 +28,7 @@ width: 250px; } } - .bp5-popover-content { + .bp6-popover-content { max-height: 20rem; overflow-y: auto; } diff --git a/frontend/css/panels/connectivity.scss b/frontend/css/panels/connectivity.scss index e17e48973d..7ae52dc507 100644 --- a/frontend/css/panels/connectivity.scss +++ b/frontend/css/panels/connectivity.scss @@ -30,7 +30,7 @@ } .connectivity-popover-portal { - .bp5-transition-container { + .bp6-transition-container { z-index: 999; } .connectivity-popover { diff --git a/frontend/css/panels/controls.scss b/frontend/css/panels/controls.scss index 5ed17fc032..acd3dd5785 100644 --- a/frontend/css/panels/controls.scss +++ b/frontend/css/panels/controls.scss @@ -2,7 +2,7 @@ @use "sass:color"; .controls-popover-portal { - .bp5-transition-container { + .bp6-transition-container { z-index: 999; } .controls-popover { @@ -30,7 +30,7 @@ display: inline-block; } } - .move-settings.bp5-popover-wrapper { + .move-settings.bp6-popover-wrapper { position: absolute; top: 10rem; right: 2rem; @@ -89,17 +89,17 @@ margin-top: 15px; width: auto; border: 0; - .bp5-popover-wrapper { + .bp6-popover-wrapper { line-height: 0; } .fa-camera { - .bp5-popover-wrapper { + .bp6-popover-wrapper { z-index: 1; width: 4rem; height: 4rem; margin-top: -3rem; margin-left: -1rem; - .bp5-popover-target { + .bp6-popover-target { width: 100%; height: 100%; } @@ -139,16 +139,16 @@ label { color: $off_white; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 0.5rem; color: $off_white; } - .bp5-popover-content { + .bp6-popover-content { width: 310px; background: $dark_gray; } - .bp5-popover-arrow-fill { + .bp6-popover-arrow-fill { fill: $dark_gray; } .fa-anchor { @@ -201,7 +201,7 @@ .camera-message, .movement-message { - .bp5-popover-content { + .bp6-popover-content { width: unset !important; } } @@ -231,6 +231,9 @@ } .bot-position-rows { + .axis-display-group-item { + position: relative; + } .missed-step-indicator-wrapper { position: absolute; top: -1.5rem; @@ -247,21 +250,21 @@ font-style: normal; } } - .bp5-popover-wrapper { - .bp5-popover-target { + .bp6-popover-wrapper { + .bp6-popover-target { display: block; } } - .bp5-popover { - .bp5-popover-arrow { + .bp6-popover { + .bp6-popover-arrow { svg { transform: rotate(-90deg) translate(1px) !important; - .bp5-popover-arrow-fill { + .bp6-popover-arrow-fill { fill: $dark_gray; } } } - .bp5-popover-content { + .bp6-popover-content { background: $dark_gray; } } diff --git a/frontend/css/panels/curves.scss b/frontend/css/panels/curves.scss index b7fa375010..3aa80d6187 100644 --- a/frontend/css/panels/curves.scss +++ b/frontend/css/panels/curves.scss @@ -2,18 +2,18 @@ @use "sass:color"; .curve-svg-wrapper { - .bp5-popover-target { + .bp6-popover-target { width: 100%; } } .warning-line-text-popover { border-radius: 5px; - .bp5-popover-content { + .bp6-popover-content { background: $dark_gray; border-radius: 5px; } - .bp5-popover-arrow { + .bp6-popover-arrow { display: none; } p { @@ -147,7 +147,7 @@ label { margin-top: 0 !important; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 1rem; } diff --git a/frontend/css/panels/events.scss b/frontend/css/panels/events.scss index f6502f820e..be847e876f 100644 --- a/frontend/css/panels/events.scss +++ b/frontend/css/panels/events.scss @@ -65,7 +65,7 @@ overflow-x: hidden; padding: 1rem 1rem 0; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; margin-left: 0.5rem; &.input-error-wrapper { diff --git a/frontend/css/panels/help.scss b/frontend/css/panels/help.scss index e5b8fd672f..81116d2d63 100644 --- a/frontend/css/panels/help.scss +++ b/frontend/css/panels/help.scss @@ -41,11 +41,11 @@ body:has(.app.dark) { padding: 1rem; font-size: 1.25rem; } - .bp5-collapse { + .bp6-collapse { overflow: visible; grid-column: span 2; } - .bp5-collapse-body { + .bp6-collapse-body { position: relative; z-index: 1; margin: -1rem; @@ -115,7 +115,7 @@ body:has(.app.dark) { button { float: none; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { margin-left: 1rem; } } diff --git a/frontend/css/panels/jobs.scss b/frontend/css/panels/jobs.scss index 9d79d1d65e..8ad9210965 100644 --- a/frontend/css/panels/jobs.scss +++ b/frontend/css/panels/jobs.scss @@ -18,7 +18,7 @@ .jobs-tab { overflow-y: scroll; max-height: 26rem; - &.bp5-popover { + &.bp6-popover { margin-top: 1.5rem; } table { @@ -26,10 +26,9 @@ p { padding: 1rem; } + .job-status, .job-name { - max-width: 20rem; - overflow: hidden; - text-overflow: ellipsis; + width: 20rem; } thead { position: sticky; @@ -42,7 +41,6 @@ } th, td { - white-space: nowrap; font-size: 1.2rem; padding: 0.75rem; } @@ -51,6 +49,7 @@ } .right-align { text-align: right; + width: 0; } .progress { position: absolute; @@ -68,7 +67,7 @@ } .jobs-panel-portal { - .bp5-popover-content { + .bp6-popover-content { padding: 0; width: min(500px, 100vw - 1rem); max-height: calc(100vh - 10rem); diff --git a/frontend/css/panels/location_info.scss b/frontend/css/panels/location_info.scss index 447abb962b..c96538ff48 100644 --- a/frontend/css/panels/location_info.scss +++ b/frontend/css/panels/location_info.scss @@ -72,7 +72,7 @@ } .photos-footer { margin-top: 1rem; - .bp5-popover-wrapper { + .bp6-popover-wrapper { margin-top: 3px; } } diff --git a/frontend/css/panels/logs.scss b/frontend/css/panels/logs.scss index b0a4d2f664..2ef734e984 100644 --- a/frontend/css/panels/logs.scss +++ b/frontend/css/panels/logs.scss @@ -78,7 +78,7 @@ font-family: monospace; } } - .bp5-slider-unlabeled { + .bp6-slider-unlabeled { margin: 0 1rem; } } diff --git a/frontend/css/panels/peripherals.scss b/frontend/css/panels/peripherals.scss index e1737d6d5d..ebc396c292 100644 --- a/frontend/css/panels/peripherals.scss +++ b/frontend/css/panels/peripherals.scss @@ -76,7 +76,7 @@ .slider-container { padding-left: 0.5rem; padding-right: 1rem; - .bp5-slider { + .bp6-slider { min-width: 100%; } } diff --git a/frontend/css/panels/photos.scss b/frontend/css/panels/photos.scss index d7d18138a4..bd907613cb 100644 --- a/frontend/css/panels/photos.scss +++ b/frontend/css/panels/photos.scss @@ -92,7 +92,7 @@ float: none; } } - .weed-detection-grid .bp5-slider { + .weed-detection-grid .bp6-slider { grid-column: span 2; } } diff --git a/frontend/css/panels/plants.scss b/frontend/css/panels/plants.scss index 20daa3b673..b0649d1537 100644 --- a/frontend/css/panels/plants.scss +++ b/frontend/css/panels/plants.scss @@ -64,7 +64,7 @@ font-family: "Inknut Antiqua"; font-weight: bold; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { position: absolute; right: 0; } diff --git a/frontend/css/panels/sequence_steps.scss b/frontend/css/panels/sequence_steps.scss index 9a3ffb4117..c2aee9a940 100644 --- a/frontend/css/panels/sequence_steps.scss +++ b/frontend/css/panels/sequence_steps.scss @@ -208,14 +208,6 @@ &.fa-code { font-weight: bold; } - &.fa-thumb-tack, - &.fa-font, - &.fa-code { - color: $placeholder_gray; - &.enabled { - color: $dark_gray; - } - } } } @@ -388,7 +380,7 @@ .expandable-header { font-size: 1.6rem; } - .bp5-collapse { + .bp6-collapse { overflow: hidden; } p { diff --git a/frontend/css/panels/sequences.scss b/frontend/css/panels/sequences.scss index e72b210aee..97edf70088 100644 --- a/frontend/css/panels/sequences.scss +++ b/frontend/css/panels/sequences.scss @@ -9,6 +9,9 @@ align-items: stretch; gap: 0; } + .sequence-steps { + margin-right: 0; + } } .designer-sequence-list-panel { @@ -40,7 +43,14 @@ } } .add-command-button-container { - display: inline; + display: block; + height: auto; + .bp6-collapse { + padding: 0; + } + .bp6-collapse-body { + padding: 0; + } } .drag-drop-area { display: none; @@ -55,8 +65,7 @@ } .sequence-editor-panel { - background: $light_gray; - padding: 0 1rem; + background: var(--main-bg); height: calc(100vh - 8.5rem); overflow: scroll; @media screen and (max-width: 767px) { @@ -72,7 +81,7 @@ h3 { margin-top: 1rem; } - .bp5-popover-target .saucer { + .bp6-popover-target .saucer { float: left; } @media screen and (max-width: 767px) { @@ -99,11 +108,6 @@ } } } - .copy-item { - &:hover { - background: $lighter_gray !important; - } - } } .farm-event-form-content, @@ -173,7 +177,6 @@ } .upgrade-compare-banner { display: flex; - margin-left: -15px; box-shadow: 0 5px 10px -5px $translucent; .copy-item { width: 100%; @@ -182,15 +185,15 @@ text-align: center; cursor: pointer; &:hover { - background: $light_gray; + background: var(--secondary-bg); } label { cursor: pointer; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; } - .bp5-button-text, + .bp6-button-text, p { display: inline-block; width: max-content; @@ -224,23 +227,38 @@ line-height: 2.25rem; color: $white; } - .bp5-button { + .bp6-button { padding-left: 0.5rem; } - .bp5-button-text { + .bp6-button-text { margin-left: 0; padding-left: 0; line-height: 1.5rem; } } &.selected { - border-bottom: 3px solid $dark_gray; + border-bottom: 3px solid var(--text-color); } } } } +.designer-sequence-editor-panel-content { + padding: 0; +} + +.sequence-actions-grid { + grid-template-columns: auto auto 1fr; + justify-items: right; +} + .sequence-editor-sections { + .bp6-collapse { + padding: 0 1rem; + .bp6-collapse-body { + padding-bottom: 1rem; + } + } .sequence-description { background: var(--secondary-bg); border-radius: 0.5rem; @@ -403,7 +421,6 @@ .sequence-section-header { position: relative; cursor: pointer; - margin: 0 -1rem; padding: 0 1rem; height: 4rem; line-height: 3.75rem; @@ -419,7 +436,7 @@ font-size: 2rem; margin-right: 0.5rem; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: contents; } } @@ -434,17 +451,12 @@ } .sequence-editor-tools { - input { - margin: 0.75rem; - } .title { - padding: 1.25rem; font-size: 1.6rem; font-weight: bold; } &.page { i { - color: $dark_gray; &.inactive { color: $placeholder_gray; } @@ -478,7 +490,8 @@ .sequence-editor-tools, .regimen-editor-tools { - border-bottom: 1.5px solid $translucent1; + border-bottom: 1.5px solid var(--border-color); + padding: 0.5rem 1rem; } .preview-variables { @@ -500,7 +513,7 @@ margin: 0 3.5rem; } .location-form-content { - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; margin-left: 1rem; } @@ -529,10 +542,8 @@ .license { p { - padding-bottom: 1rem; - line-height: 1.5rem; - margin-right: 1rem; - margin-left: 1.5rem; + padding-bottom: 0.75rem; + line-height: 1.25rem; } } @@ -545,7 +556,7 @@ &.read-only { margin-top: 1rem; margin-bottom: 1rem; - .bp5-control, + .bp6-control, .filter-search, textarea, .input, @@ -560,9 +571,15 @@ overflow-y: auto; overflow-x: hidden; max-height: calc(100vh - 8rem); + .text-input-wrapper { + input { + padding: 0; + } + } .commands, .pinned-sequences { display: flex; flex-wrap: wrap; + align-content: start; gap: 0.5rem; } .step-button { @@ -596,6 +613,7 @@ height: 10rem; overflow-y: auto; overflow-x: hidden; + padding: 1rem 0; } .text-input-wrapper { border-bottom-color: $translucent8_white !important; @@ -626,7 +644,7 @@ margin-left: -1rem; color: $white; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 1rem; i { @@ -906,10 +924,10 @@ .folder-list-item, .sequence-list-item { padding-left: 3rem; - .bp5-popover-wrapper.color-picker { + .bp6-popover-wrapper.color-picker { position: absolute; line-height: 0; - .bp5-popover-target { + .bp6-popover-target { width: 2rem; height: 3.5rem; } @@ -924,7 +942,7 @@ } .folder-list-item { padding-left: 0; - .bp5-popover-wrapper.color-picker { + .bp6-popover-wrapper.color-picker { margin-left: 2.5rem; } .color-picker { @@ -974,13 +992,13 @@ .folder-settings-icon, .sequence-item-action-menu { - .bp5-popover-content { + .bp6-popover-content { padding: 0.5rem; } } .sequence-item-description { - .bp5-popover-content { + .bp6-popover-content { max-height: 20rem; overflow-y: auto; width: 32rem; @@ -1102,7 +1120,7 @@ } } &.last { - .bp5-collapse { + .bp6-collapse { margin-top: 1rem; margin-right: 2.5rem; } @@ -1123,13 +1141,12 @@ margin: auto; text-align: center; height: 3rem; - &.open { - margin-left: 1rem; - padding-right: 3rem; - } .add-command { display: none; } + .step-button-cluster { + margin: 0; + } } @media screen and (max-width: 767px) { display: block; @@ -1158,29 +1175,13 @@ .imported-banner, .import-banner { - margin: 0 -15px 0 -15px; - padding: 1rem 1.5rem 1rem 2rem; + padding: 1rem; background: color.adjust($orange, $alpha: -0.4); - button { - margin-right: 1rem; - } - label { - display: inline-block; - padding-left: 1rem; - } - .bp5-popover-wrapper { - display: inline-block; - margin-left: 1rem; - } - p { + .bp6-popover-wrapper { margin-left: 1rem; } } -.import-banner { - margin-bottom: 1rem; -} - .imported-banner { background: color.adjust($blue, $alpha: -0.4); } @@ -1295,7 +1296,7 @@ $border_width: 1.4px; .versions-table { margin-top: 2rem; text-align: left; - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; margin-left: 1rem; } diff --git a/frontend/css/panels/settings.scss b/frontend/css/panels/settings.scss index 7d1cfab18e..8ed2274b1e 100644 --- a/frontend/css/panels/settings.scss +++ b/frontend/css/panels/settings.scss @@ -17,10 +17,10 @@ &.setting { gap: 0.5rem; } - .bp5-collapse { + .bp6-collapse { grid-column: span 2; } - .bp5-collapse-body { + .bp6-collapse-body { display: grid; gap: 1rem; } @@ -200,7 +200,7 @@ animation: fade-out 1s 0.4s forwards; } .save-error { - .bp5-popover-content { + .bp6-popover-content { background: $dark_gray; min-width: 200px; p { @@ -208,7 +208,7 @@ color: $off_white; } } - .bp5-popover-arrow-fill { + .bp6-popover-arrow-fill { fill: $dark_gray; } } diff --git a/frontend/css/panels/setup_wizard.scss b/frontend/css/panels/setup_wizard.scss index a3da1d964b..d2506cf73c 100644 --- a/frontend/css/panels/setup_wizard.scss +++ b/frontend/css/panels/setup_wizard.scss @@ -64,12 +64,12 @@ h2 { margin-top: 0; } - .bp5-collapse-body { + .bp6-collapse-body { margin-bottom: 2rem; } } .wizard-step { - .bp5-collapse-body { + .bp6-collapse-body { margin-bottom: 0; } img { @@ -210,6 +210,12 @@ margin-top: 0; } } + .move-settings { + display: none; + } + .tool-slot-search-item:hover { + background: unset !important; + } } .troubleshooting { a { @@ -264,7 +270,7 @@ } } .filter-search { - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: unset; margin-left: unset; } diff --git a/frontend/css/panels/tools.scss b/frontend/css/panels/tools.scss index c033c588f2..829660c20f 100644 --- a/frontend/css/panels/tools.scss +++ b/frontend/css/panels/tools.scss @@ -10,7 +10,7 @@ .tool-search-item, .tool-slot-search-item { .filter-search { - .bp5-button { + .bp6-button { min-height: 2.5rem; max-height: 2.5rem; span { @@ -43,7 +43,7 @@ label { margin: 0; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; } .help-icon { @@ -52,7 +52,7 @@ font-size: 1.4rem; } } - button:not(.bp5-button) { + button:not(.bp6-button) { display: block; margin-left: auto; float: none; @@ -157,7 +157,7 @@ } .flow-rate-input { - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 0.5rem; } diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts new file mode 100644 index 0000000000..d15ca82091 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -0,0 +1,295 @@ +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { + fakeFbosConfig, + fakeFirmwareConfig, + fakeWebAppConfig, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +let mockLocked = false; +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ + resources: mockResources, + bot: { + hardware: { + location_data: { position: { x: 0, y: 0, z: 0 } }, + informational_settings: { locked: mockLocked }, + }, + }, + }), + }, +})); + +jest.mock("lodash", () => ({ + ...jest.requireActual("lodash"), + random: () => 0, +})); + +import { TOAST_OPTIONS } from "../../../toast/constants"; +import { info } from "../../../toast/toast"; +import { eStop, expandActions, runActions, setCurrent } from "../actions"; + +describe("runActions()", () => { + beforeEach(() => { + console.log = jest.fn(); + mockLocked = false; + }); + + it("runs actions", () => { + jest.useFakeTimers(); + runActions( + [ + { type: "send_message", args: ["info", "Hello, world!", "toast", "{}"] }, + ], + ); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith("Hello, world!", TOAST_OPTIONS().info); + }); + + it("runs actions: missing", () => { + jest.useFakeTimers(); + runActions( + [ + { type: "wait_ms", args: [10000] }, + { type: "send_message", args: ["info", "Hello, world!", "toast", "{}"] }, + ], + ); + eStop(); + jest.runAllTimers(); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs actions: eStop only notifies once", () => { + mockLocked = true; + jest.useFakeTimers(); + runActions( + [ + { type: "wait_ms", args: [1000] }, + { type: "wait_ms", args: [1000] }, + { type: "wait_ms", args: [1000] }, + ], + ); + jest.runAllTimers(); + expect(info).toHaveBeenCalledTimes(1); + }); +}); + +describe("expandActions()", () => { + beforeEach(() => { + setCurrent({ x: 0, y: 0, z: 0 }); + localStorage.removeItem("timeStepMs"); + localStorage.removeItem("mmPerSecond"); + console.log = jest.fn(); + mockResources = buildResourceIndex([ + fakeFirmwareConfig(), + fakeFbosConfig(), + fakeWebAppConfig(), + ]); + mockLocked = false; + }); + + it("chunks movements: default", () => { + expect(expandActions([ + { type: "move_absolute", args: [300, 0, 0] }, + ], [])).toEqual([ + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [125, 0, 0] }, + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [250, 0, 0] }, + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [300, 0, 0] }, + ]); + }); + + it("chunks movements: lands on target", () => { + expect(expandActions([ + { type: "move_absolute", args: [125, 0, 0] }, + ], [])).toEqual([ + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [125, 0, 0] }, + ]); + }); + + it("chunks movements: custom", () => { + localStorage.setItem("timeStepMs", "1000"); + localStorage.setItem("mmPerSecond", "1000"); + expect(expandActions([ + { type: "move_absolute", args: [300, 0, 0] }, + ], [])).toEqual([ + { type: "wait_ms", args: [1000] }, + { type: "expanded_move_absolute", args: [300, 0, 0] }, + ]); + }); + + it("doesn't chunk movements", () => { + localStorage.setItem("DISABLE_CHUNKING", "true"); + expect(expandActions([ + { type: "move_absolute", args: [2000, 0, 0] }, + ], [])).toEqual([ + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [2000, 0, 0] }, + ]); + }); + + it("chunks movements: warns", () => { + expect(expandActions([ + { type: "_move", args: [JSON.stringify([{ kind: "foo", args: {} }])] }, + ], [])).toEqual([ + { + type: "send_message", + args: [ + "warn", + "not yet supported: item kind: foo", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + ], + }, + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [0, 0, 0] }, + ]); + }); + + it("expands take_photo", () => { + expect(expandActions([ + { type: "take_photo", args: [] }, + ], [])).toEqual([ + { + type: "send_message", + args: [ + "info", + "Taking photo", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { type: "wait_ms", args: [2000] }, + { type: "take_photo", args: [0, 0, 0] }, + { + type: "send_message", + args: [ + "info", + "Uploaded image:", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + ]); + }); + + it("expands calibrate_camera", () => { + expect(expandActions([ + { type: "calibrate_camera", args: [] }, + ], [])).toEqual([ + { + type: "send_message", + args: [ + "info", + "Calibrating camera", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { type: "wait_ms", args: [12000] }, + { type: "take_photo", args: [0, 0, 0] }, + { + type: "send_message", + args: [ + "info", + "Uploaded image:", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + ]); + }); + + it("expands detect_weeds", () => { + expect(expandActions([ + { type: "detect_weeds", args: [] }, + ], [])).toEqual([ + { + type: "send_message", + args: [ + "info", + "Running weed detector", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { type: "wait_ms", args: [12000] }, + { type: "take_photo", args: [0, 0, 0] }, + { + type: "send_message", + args: [ + "info", + "Uploaded image:", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { + type: "create_point", + args: [JSON.stringify({ + name: "Weed", + pointer_type: "Weed", + x: 0, + y: 0, + z: -500, + meta: { color: "red", created_by: "plant-detection" }, + radius: 50, + plant_stage: "pending", + })], + }, + ]); + }); + + it("expands measure_soil_height", () => { + expect(expandActions([ + { type: "measure_soil_height", args: [] }, + ], [])).toEqual([ + { + type: "send_message", + args: [ + "info", + "Executing Measure Soil Height", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { type: "wait_ms", args: [12000] }, + { type: "take_photo", args: [0, 0, 0] }, + { + type: "send_message", + args: [ + "info", + "Uploaded image:", + "", + "{\"x\":0,\"y\":0,\"z\":0}", + 3, + ], + }, + { + type: "create_point", + args: [JSON.stringify({ + name: "Soil Height", + pointer_type: "GenericPointer", + x: 0, + y: 0, + z: -500, + meta: { at_soil_level: "true" }, + radius: 0, + })], + }, + ]); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts new file mode 100644 index 0000000000..a7020bc92a --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts @@ -0,0 +1,837 @@ +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { + fakeFbosConfig, + fakeFirmwareConfig, + fakePlant, + fakeTool, + fakeToolSlot, + fakeWebAppConfig, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ + resources: mockResources, + bot: { + hardware: { + location_data: { position: { x: 0, y: 0, z: 0 } }, + informational_settings: { locked: false }, + }, + }, + }), + }, +})); + +jest.mock("../../../three_d_garden/triangle_functions", () => ({ + getZFunc: jest.fn(() => () => 3), +})); + +import { + AxisAddition, AxisOverwrite, Move, MoveBodyItem, ParameterApplication, +} from "farmbot"; +import { addDefaults, calculateMove } from "../calculate_move"; +import { setCurrent } from "../actions"; + +describe("addDefaults()", () => { + it("adds defaults", () => { + const config = fakeFbosConfig(); + config.body.default_axis_order = "safe_z"; + mockResources = buildResourceIndex([config]); + expect(addDefaults([])).toEqual([{ kind: "safe_z", args: {} }]); + }); + + it("doesn't add defaults", () => { + expect(addDefaults([ + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, + ])).toEqual([ + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, + ]); + }); +}); + +describe("calculateMove()", () => { + beforeEach(() => { + setCurrent({ x: 0, y: 0, z: 0 }); + localStorage.removeItem("timeStepMs"); + localStorage.removeItem("mmPerSecond"); + console.log = jest.fn(); + mockResources = buildResourceIndex([ + fakeFirmwareConfig(), + fakeFbosConfig(), + fakeWebAppConfig(), + ]); + }); + + it("handles number single axis addition", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_addition", + args: { + axis: "x", + axis_operand: { kind: "numeric", args: { number: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 2, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles number all axis addition", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_addition", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 2, y: 3, z: 4 }], warnings: [] }); + }); + + it("handles coordinate single axis addition", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_addition", + args: { + axis: "x", + axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 2, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles coordinate all axis addition", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_addition", + args: { + axis: "all", + axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 2, y: 4, z: 6 }], warnings: [] }); + }); + + it("handles number single axis overwrite", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "x", + axis_operand: { kind: "numeric", args: { number: 3 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 3, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles number all axis overwrite", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) + .toEqual({ moves: [{ x: 1, y: 1, z: 1 }], warnings: [] }); + }); + + it("handles coordinate single axis overwrite", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "x", + axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 1, y: 0, z: 0 }], warnings: [] }); + }); + + it("handles coordinate all axis overwrite", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles tool single axis overwrite", () => { + const tool = fakeTool(); + tool.body.id = 1; + const slot = fakeToolSlot(); + slot.body.tool_id = 1; + slot.body.x = 1; + slot.body.y = 2; + slot.body.z = 3; + mockResources = buildResourceIndex([tool, slot]); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "x", + axis_operand: { kind: "tool", args: { tool_id: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 1, y: 0, z: 0 }], warnings: [] }); + }); + + it("handles tool all axis overwrite", () => { + const tool = fakeTool(); + tool.body.id = 1; + const slot = fakeToolSlot(); + slot.body.tool_id = 1; + slot.body.x = 1; + slot.body.y = 2; + slot.body.z = 3; + slot.body.gantry_mounted = true; + mockResources = buildResourceIndex([tool, slot]); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "tool", args: { tool_id: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles missing tool", () => { + mockResources = buildResourceIndex([]); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "tool", args: { tool_id: 1 } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 0, z: 0 }], warnings: [] }); + }); + + it("handles coordinate identifier all axis overwrite", () => { + mockResources = buildResourceIndex([]); + const variables: ParameterApplication[] = [ + { + kind: "parameter_application", + args: { + label: "parent", + data_value: { + kind: "coordinate", + args: { x: 1, y: 2, z: 3 }, + }, + }, + }, + ]; + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "identifier", args: { label: "parent" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles point identifier all axis overwrite", () => { + const point = fakePlant(); + point.body.id = 1; + point.body.x = 1; + point.body.y = 2; + point.body.z = 3; + mockResources = buildResourceIndex([point]); + const variables: ParameterApplication[] = [ + { + kind: "parameter_application", + args: { + label: "parent", + data_value: { + kind: "point", + args: { pointer_id: 1, pointer_type: "Plant" }, + }, + }, + }, + ]; + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "identifier", args: { label: "parent" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); + }); + + it("handles missing point", () => { + mockResources = buildResourceIndex([]); + const variables: ParameterApplication[] = [ + { + kind: "parameter_application", + args: { + label: "parent", + data_value: { + kind: "point", + args: { pointer_id: 1, pointer_type: "Plant" }, + }, + }, + }, + ]; + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "identifier", args: { label: "parent" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) + .toEqual({ moves: [{ x: 0, y: 0, z: 0 }], warnings: [] }); + }); + + it("handles missing variables", () => { + mockResources = buildResourceIndex([]); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "identifier", args: { label: "parent" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, undefined)) + .toEqual({ + moves: [{ x: 0, y: 0, z: 0 }], + warnings: ["identifier location kind: undefined"], + }); + }); + + it("handles soil height z axis overwrite", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "z", + axis_operand: { kind: "special_value", args: { label: "soil_height" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); + }); + + it("handles soil height z axis overwrite: triangle data", () => { + sessionStorage.setItem("triangles", "[\"foo\"]"); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "z", + axis_operand: { kind: "special_value", args: { label: "soil_height" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); + }); + + it("handles safe height z axis overwrite", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_home_up_z = 0; + const fbosConfig = fakeFbosConfig(); + fbosConfig.body.safe_height = 3; + mockResources = buildResourceIndex([fbosConfig, firmwareConfig]); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "z", + axis_operand: { kind: "special_value", args: { label: "safe_height" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); + }); + + it("handles soil height z axis overwrite: wrong label", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "z", + axis_operand: { kind: "special_value", args: { label: "nope" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ + moves: [{ x: 0, y: 0, z: 0 }], + warnings: ["special_value label: nope"], + }); + }); + + it("handles safe_z", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { + kind: "speed_overwrite", + args: { + axis: "all", + speed_setting: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "safe_z", args: {} }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 0 }, + { x: 100, y: 100, z: 0 }, + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: xyz", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,xy", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,xy", route: "in_order" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 100 }, + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,xy, high from low", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 0 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,xy", route: "high" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 0 }, + { x: 0, y: 0, z: 0 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,xy, high from high", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 10 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,xy", route: "high" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 0 }, [])) + .toEqual({ + moves: [ + { x: 10, y: 10, z: 0 }, + { x: 10, y: 10, z: 10 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,xy, low from high", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,xy", route: "low" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 100 }, + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,xy, low from low", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 10 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,xy", route: "low" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 10, y: 10, z: 50 }, + { x: 10, y: 10, z: 10 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: xy,z, high from low", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 0 } }, + }, + }, + { kind: "axis_order", args: { grouping: "xy,z", route: "high" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 0 }, + { x: 0, y: 0, z: 0 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: x,z,y, high from low", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 0 } }, + }, + }, + { kind: "axis_order", args: { grouping: "x,z,y", route: "high" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 0 }, + { x: 0, y: 50, z: 0 }, + { x: 0, y: 0, z: 0 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: x,z,y, high from high", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "axis_order", args: { grouping: "x,z,y", route: "high" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 100, z: 50 }, + { x: 100, y: 100, z: 50 }, + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles axis_order: z,y,x", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 100 } }, + }, + }, + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual({ + moves: [ + { x: 50, y: 50, z: 100 }, + { x: 50, y: 100, z: 100 }, + { x: 100, y: 100, z: 100 }, + ], + warnings: [], + }); + }); + + it("handles unknown pieces", () => { + const variables: ParameterApplication[] = [ + { + kind: "parameter_application", + args: { + label: "parent", + data_value: { + kind: "foo" as ParameterApplication["args"]["data_value"]["kind"], + args: { pointer_id: 1, pointer_type: "Plant" }, + } as ParameterApplication["args"]["data_value"], + }, + }, + ]; + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "foo" as MoveBodyItem["kind"], + args: {}, + } as MoveBodyItem, + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { + kind: "bar" as AxisOverwrite["args"]["axis_operand"]["kind"], + args: {}, + } as AxisOverwrite["args"]["axis_operand"], + }, + }, + { + kind: "axis_addition", + args: { + axis: "all", + axis_operand: { + kind: "bar" as AxisAddition["args"]["axis_operand"]["kind"], + args: {}, + } as AxisAddition["args"]["axis_operand"], + }, + }, + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "identifier", args: { label: "parent" } }, + }, + }, + ], + }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) + .toEqual({ + moves: [{ x: 0, y: 0, z: 0 }], + warnings: [ + "item kind: foo", + "axis_overwrite axis_operand kind: bar", + "axis_addition axis_operand kind: bar", + "identifier location kind: foo", + ], + }); + }); + + it("handles missing body", () => { + const command: Move = { kind: "move", args: {} }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ moves: [{ x: 0, y: 0, z: 0 }], warnings: [] }); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts new file mode 100644 index 0000000000..5ca0472039 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -0,0 +1,1776 @@ +import { + buildResourceIndex, + fakeDevice, +} from "../../../__test_support__/resource_index_builder"; +import { + fakeFirmwareConfig, + fakeWebAppConfig, + fakeFbosConfig, + fakePoint, + fakeSequence, fakeTool, + fakeToolSlot, + fakePointGroup, + fakePlant, + fakeWeed, + fakeCurve, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +let mockLocked = false; +let mockJobs: Record = {}; +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ + resources: mockResources, + bot: { + hardware: { + informational_settings: { locked: mockLocked }, + jobs: mockJobs, + }, + }, + }), + }, +})); + +jest.mock("../../../api/crud", () => ({ + edit: jest.fn(), + save: jest.fn(), + initSave: jest.fn(), + init: jest.fn(() => ({ payload: { uuid: "" } })), +})); + +jest.mock("lodash", () => ({ + ...jest.requireActual("lodash"), + random: () => 0, +})); + +import { + Execute, FindHome, Move, ParameterApplication, TaggedSequence, +} from "farmbot"; +import { Actions } from "../../../constants"; +import { store } from "../../../redux/store"; +import { error, info } from "../../../toast/toast"; +import { + collectDemoSequenceActions, + csToLua, + runDemoLuaCode, + runDemoSequence, +} from ".."; +import { TOAST_OPTIONS } from "../../../toast/constants"; +import { edit, init, initSave, save } from "../../../api/crud"; +import { setCurrent } from "../actions"; +import { API } from "../../../api"; + +API.setBaseUrl(""); + +describe("runDemoSequence()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + jest.useFakeTimers(); + }); + + it("runs sequence with number variable", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Number\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Number", + data_value: { kind: "numeric", args: { number: 1 } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1"); + }); + + it("runs sequence with text variable", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Text\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Text", + data_value: { kind: "text", args: { string: "text" } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("text"); + }); + + it("runs sequence with coordinate variable", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Location\").x)" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + }); + + it("runs sequence with point variable", () => { + const point = fakePoint(); + point.body.id = 1; + point.body.x = 0; + mockResources = buildResourceIndex([point]); + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Location\").x)" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { + kind: "point", + args: { pointer_id: 1, pointer_type: "GenericPointer" }, + }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + }); + + it("runs sequence with point variable: no points", () => { + mockResources = buildResourceIndex([]); + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Location\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { + kind: "point", + args: { pointer_id: 1, pointer_type: "GenericPointer" }, + }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("undefined"); + }); + + it("runs sequence with tool variable", () => { + const slot = fakeToolSlot(); + slot.body.tool_id = 1; + mockResources = buildResourceIndex([slot]); + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Location\").tool_id)" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { kind: "tool", args: { tool_id: 1 } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1"); + }); + + it("runs sequence with tool variable: not tools", () => { + mockResources = buildResourceIndex([]); + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Location\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { kind: "tool", args: { tool_id: 1 } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("undefined"); + }); + + it("runs sequence with point group variable", () => { + const point1 = fakePoint(); + point1.body.id = 1; + const point2 = fakePoint(); + point2.body.id = 2; + const point3 = fakePoint(); + point3.body.id = 3; + const group = fakePointGroup(); + group.body.id = 1; + group.body.point_ids = [1, 2, 3]; + const sequence = fakeSequence(); + sequence.body.id = 1; + sequence.body.body = [{ + kind: "send_message", + args: { message: "text", message_type: "info" }, + }]; + const ri = buildResourceIndex([ + group, point1, point2, point3, sequence, + ]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Location", + data_value: { kind: "point_group", args: { point_group_id: 1 } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledTimes(3); + expect(init).toHaveBeenCalledWith("Log", { + message: "text", + type: "info", + channels: ["undefined"], + verbosity: undefined, + x: 0, + y: 0, + z: 0, + }); + }); + + it("runs sequence with other variable", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Other\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Other", + data_value: { kind: "identifier", args: { label: "var" } }, + }, + }]; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "Variable \"Other\" of type identifier not implemented.", + type: "error", + channels: ["undefined"], + verbosity: undefined, + x: 0, + y: 0, + z: 0, + }); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(error).not.toHaveBeenCalled(); + }); + + it("runs non-lua sequence step", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "send_message", + args: { message: "text", message_type: "info" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, []); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "text", + type: "info", + channels: ["undefined"], + verbosity: undefined, + x: 0, + y: 0, + z: 0, + }); + expect(console.log).toHaveBeenCalledTimes(1); + }); + + it("runs move sequence step", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + const sequence = fakeSequence(); + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_addition", + args: { + axis: "all", + axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + }, + }, + ], + }; + sequence.body.body = [command]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, []); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 2, y: 4, z: 6 }, + }); + expect(console.log).toHaveBeenCalledTimes(1); + }); + + it("applies sequence variables", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "toast(variable(\"Variable\"))" }, + }]; + sequence.body.args.locals.body = [{ + kind: "variable_declaration", + args: { + label: "Variable", + data_value: { kind: "text", args: { string: "v" } }, + }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith("v", TOAST_OPTIONS().info); + expect(console.log).toHaveBeenCalledTimes(1); + expect(error).not.toHaveBeenCalled(); + }); + + it("doesn't duplicate sequence variables", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "toast(variable(\"Variable\"))" }, + }]; + sequence.body.args.locals.body = [{ + kind: "variable_declaration", + args: { + label: "Variable", + data_value: { kind: "text", args: { string: "v" } }, + }, + }]; + sequence.body.id = 1; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Variable", + data_value: { kind: "text", args: { string: "abc" } }, + }, + }]; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith("abc", TOAST_OPTIONS().info); + expect(console.log).toHaveBeenCalledTimes(1); + expect(error).not.toHaveBeenCalled(); + }); + + it("handles missing variable name sets", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Number\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + ri.sequenceMetas = { [sequence.uuid]: { foo: undefined } }; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "Variable \"Number\" of type undefined not implemented.", + type: "error", + channels: ["undefined"], + verbosity: undefined, + x: 2, + y: 4, + z: 6, + }); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(error).not.toHaveBeenCalled(); + }); + + it("handles missing variables", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ + kind: "lua", + args: { lua: "print(variable(\"Number\"))" }, + }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + ri.sequenceMetas = {}; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "Variable \"Number\" of type undefined not implemented.", + type: "error", + channels: ["undefined"], + verbosity: undefined, + x: 2, + y: 4, + z: 6, + }); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(error).not.toHaveBeenCalled(); + }); + + it("handles missing sequence body", () => { + const sequence = fakeSequence(); + sequence.body.body = undefined; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + if (ri.references[0]) { + (ri.references[0] as TaggedSequence).body.body = undefined; + } + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).toHaveBeenCalledTimes(1); + expect(info).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("handles load error", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ kind: "lua", args: { lua: "!" } }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).toHaveBeenCalledTimes(1); + expect(info).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + "Lua load error: [string \"!\"]:1: unexpected symbol near '!'", + ); + }); + + it("handles call error", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ kind: "lua", args: { lua: "return blah + 5" } }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).toHaveBeenCalledTimes(1); + expect(info).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("Lua call error:")); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("attempt to perform arithmetic")); + }); +}); + +describe("collectDemoSequenceActions()", () => { + it("collects actions", () => { + const sequence1 = fakeSequence(); + sequence1.body.id = 1; + const findHome1: FindHome = { + kind: "find_home", + args: { axis: "x", speed: 100 }, + }; + const execute: Execute = { + kind: "execute", + args: { sequence_id: 2 }, + }; + sequence1.body.body = [findHome1, execute]; + + const sequence2 = fakeSequence(); + sequence2.body.id = 2; + const findHome2: FindHome = { + kind: "find_home", + args: { axis: "y", speed: 100 }, + }; + sequence2.body.body = [findHome2]; + + const ri = buildResourceIndex([sequence1, sequence2]).index; + const actions = collectDemoSequenceActions(0, ri, 1, []); + expect(actions).toEqual([ + { type: "find_home", args: ["x"] }, + { type: "find_home", args: ["y"] }, + ]); + expect(error).not.toHaveBeenCalled(); + }); + + it("handles circular references", () => { + const sequence1 = fakeSequence(); + sequence1.body.id = 1; + const execute2: Execute = { + kind: "execute", + args: { sequence_id: 2 }, + }; + sequence1.body.body = [execute2]; + + const sequence2 = fakeSequence(); + sequence2.body.id = 2; + const execute1: Execute = { + kind: "execute", + args: { sequence_id: 1 }, + }; + sequence2.body.body = [execute1]; + + const ri = buildResourceIndex([sequence1, sequence2]).index; + const actions = collectDemoSequenceActions(0, ri, 1, []); + expect(actions).toEqual([]); + expect(error).toHaveBeenCalledWith("Maximum call depth exceeded."); + }); +}); + +describe("runDemoLuaCode()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + jest.useFakeTimers(); + mockLocked = false; + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([ + fakeFbosConfig(), + firmwareConfig, + fakeWebAppConfig(), + ]); + }); + + it("runs print", () => { + runDemoLuaCode("print(\"Hello, world!\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Hello, world!"); + }); + + it("runs print: all", () => { + runDemoLuaCode(` + local a = 2 + 2 + function f() + end + print(a, false, true, nil, {1}, {a = {b = 1}}, f) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + "4 false true undefined [1] {\"a\":{\"b\":1}} \"\""); + }); + + it("runs garden_size", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_x = 5000; + firmwareConfig.body.movement_axis_nr_steps_y = 10000; + firmwareConfig.body.movement_axis_nr_steps_z = 12500; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + runDemoLuaCode( + "print(garden_size().x)\n" + + "print(garden_size().y)\n" + + "print(garden_size().z)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1000"); + expect(console.log).toHaveBeenCalledWith("2000"); + expect(console.log).toHaveBeenCalledWith("500"); + }); + + it("runs api: default method", () => { + const point = fakePoint(); + point.body.id = 1; + point.body.x = 0; + mockResources = buildResourceIndex([point]); + runDemoLuaCode(` + local data = api{ + url="/api/points" + } + print(type(data), #data) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs api: handles the unexpected", () => { + const point = fakePoint(); + point.body.id = 1; + point.body.x = undefined as unknown as number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (point.body as any).function = () => { }; + mockResources = buildResourceIndex([point]); + runDemoLuaCode(` + local data = api{ + url="/api/points" + } + print(type(data), #data) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs api: creates point", () => { + mockResources = buildResourceIndex([]); + runDemoLuaCode(` + api{ url="/api/points", + method="POST", + body={ + name = "test", + pointer_type = "GenericPointer", + x = 1, y = 2, z = 3, + radius = 5 }}`); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(0); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Point", { + name: "test", pointer_type: "GenericPointer", + x: 1, y: 2, z: 3, radius: 5, meta: {}, + }); + }); + + it("runs api: creates point with meta", () => { + mockResources = buildResourceIndex([]); + runDemoLuaCode(` + api{ url="/api/points", + method="POST", + body={ + name = "test", + pointer_type = "GenericPointer", + meta = { color = "red" }, + x = 1, y = 2, z = 3, + radius = 5 }}`); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(0); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Point", { + name: "test", pointer_type: "GenericPointer", + x: 1, y: 2, z: 3, radius: 5, meta: { color: "red" }, + }); + }); + + it("runs api: tools", () => { + const tool = fakeTool(); + tool.body.id = 1; + mockResources = buildResourceIndex([tool]); + runDemoLuaCode(` + local data = api{ + method = "GET", + url = "/api/tools" + } + print(type(data), #data) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs api: curves", () => { + const curve = fakeCurve(); + curve.body.id = 1; + mockResources = buildResourceIndex([curve]); + runDemoLuaCode(` + local data = api{ + method = "GET", + url = "/api/curves/1" + } + print(type(data), #data) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 0"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs api: other", () => { + mockResources = buildResourceIndex([]); + runDemoLuaCode(` + local data = api{ + method = "GET", + url = "/api/other" + } + print(data) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("false"); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith( + "Log", + { + message: "API call GET /api/other not implemented.", + type: "error", + channels: ["undefined"], + verbosity: undefined, + x: 2, + y: 4, + z: 6, + }); + }); + + it("runs get_plants", () => { + const point1 = fakePlant(); + point1.body.id = 1; + point1.body.plant_stage = "planted"; + const point2 = fakePlant(); + point2.body.id = 2; + point2.body.plant_stage = "planted"; + mockResources = buildResourceIndex([point1, point2]); + runDemoLuaCode(` + local points = get_plants() + print(#points) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs get_weeds", () => { + const point1 = fakeWeed(); + point1.body.id = 1; + const point2 = fakeWeed(); + point2.body.id = 2; + mockResources = buildResourceIndex([point1, point2]); + runDemoLuaCode(` + local points = get_weeds() + print(#points) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs get_generic_points", () => { + const point1 = fakePoint(); + point1.body.id = 1; + const point2 = fakePoint(); + point2.body.id = 2; + mockResources = buildResourceIndex([point1, point2]); + runDemoLuaCode(` + local points = get_generic_points() + print(#points) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs sort", () => { + const point1 = fakePlant(); + point1.body.id = 1; + point1.body.plant_stage = "planted"; + const point2 = fakePlant(); + point2.body.id = 2; + point2.body.plant_stage = "planted"; + mockResources = buildResourceIndex([point1, point2]); + runDemoLuaCode(` + local points = sort(get_plants(), "nn") + print(#points) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs get_group", () => { + const group = fakePointGroup(); + group.body.id = 1; + group.body.point_ids = [1, 2]; + const point1 = fakePoint(); + point1.body.id = 1; + const point2 = fakePoint(); + point2.body.id = 2; + mockResources = buildResourceIndex([group, point1, point2]); + runDemoLuaCode(` + local points = get_group(1) + print(#points) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs group", () => { + const group = fakePointGroup(); + group.body.id = 1; + group.body.point_ids = [1, 2]; + const point1 = fakePoint(); + point1.body.id = 1; + const point2 = fakePoint(); + point2.body.id = 2; + mockResources = buildResourceIndex([group, point1, point2]); + runDemoLuaCode(` + local point_ids = group(1) + print(#point_ids) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("2"); + }); + + it("runs to_unix", () => { + runDemoLuaCode(` + print(to_unix("2017-05-24T20:41:19.804Z")) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith(expect.any(String)); + }); + + it("runs inspect", () => { + runDemoLuaCode(` + print(inspect({1, 2, 3})) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("[3,2,1]"); + }); + + it("runs json.encode", () => { + runDemoLuaCode(` + print(json.encode({1, 2, 3})) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("[3,2,1]"); + }); + + it("runs json.decode", () => { + runDemoLuaCode(` + print(json.decode("{}")) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("[]"); + }); + + it("runs json.decode: handles bad json", () => { + runDemoLuaCode(` + print(json.decode("{")) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("undefined"); + }); + + it("runs cs_eval", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode(` + cs_eval{ + kind = "rpc_request", + args = { label = "", priority = 0 }, + body = { + { kind = "find_home", args = { axis = "x" } } + } + } + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 3 }, + }); + }); + + it("runs cs_eval: execute", () => { + const sequence = fakeSequence(); + sequence.body.id = 1; + sequence.body.body = [{ + kind: "send_message", + args: { message: "test", message_type: "info" }, + }]; + mockResources = buildResourceIndex([sequence]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode(` + cs_eval{ + kind = "rpc_request", + args = { label = "", priority = 0 }, + body = { + { kind = "execute", args = { sequence_id = 1 } } + } + } + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "test", + type: "info", + channels: ["undefined"], + verbosity: undefined, + x: 1, + y: 2, + z: 3, + }); + }); + + it("runs cs_eval: no body", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("cs_eval{}"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("runs toast", () => { + runDemoLuaCode("toast(\"test\", \"info\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs toast: default", () => { + runDemoLuaCode("toast(\"test\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs debug", () => { + runDemoLuaCode("debug(\"test\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "test", + type: "debug", + channels: ["undefined"], + verbosity: undefined, + x: 1, + y: 2, + z: 3, + }); + }); + + it("runs send_message", () => { + runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs send_message: multiple channels", () => { + runDemoLuaCode("send_message(\"info\", \"test\", {\"email\", \"toast\"})"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("sets job progress: working", () => { + runDemoLuaCode(` + set_job_progress("job", { + percent = 50, + status = "working", + time = os.time() * 1000 + }) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 50, + status: "working", + type: "unknown", + file_type: "", + updated_at: expect.any(Number), + time: expect.any(Number), + }], + }); + }); + + it("sets job progress: complete", () => { + runDemoLuaCode(` + set_job_progress("job", { + percent = 100, + status = "Complete", + time = os.time() * 1000 + }) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 100, + status: "Complete", + type: "unknown", + file_type: "", + updated_at: expect.any(Number), + time: undefined, + }], + }); + }); + + it("runs complete_job", () => { + runDemoLuaCode(` + complete_job("job") + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 100, + status: "Complete", + type: "unknown", + file_type: "", + updated_at: expect.any(Number), + time: undefined, + }], + }); + }); + + it("runs set_job", () => { + mockJobs.job = { percent: 0, status: "Farming" }; + runDemoLuaCode(` + set_job("job", {percent = 50}) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 50, + status: "Farming", + type: "unknown", + file_type: "", + updated_at: expect.any(Number), + time: expect.any(Number), + }], + }); + }); + + it("runs set_job: existing job is complete", () => { + mockJobs.job = { percent: 100, status: "Complete" }; + runDemoLuaCode(` + set_job("job", {time = 1000}) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 0, + status: "Working", + type: "unknown", + file_type: "", + updated_at: expect.any(Number), + time: expect.any(Number), + }], + }); + }); + + it("runs get_job", () => { + mockJobs.job = { percent: 50 }; + runDemoLuaCode(` + print(get_job("job").percent) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("50"); + }); + + it("runs get_job: handles missing", () => { + mockJobs = { "not": {} }; + runDemoLuaCode(` + print(get_job("job")) + `); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith("undefined"); + }); + + it("runs find_home: all", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("find_home(\"all\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + }); + + it("runs go_to_home: all", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("go_to_home(\"all\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + }); + + it("runs go_to_home: x", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("go_to_home(\"x\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 3 }, + }); + }); + + it("runs go_to_home: y", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("go_to_home(\"y\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 3 }, + }); + }); + + it.each<[number, number, number]>([ + [0, 0, 100], + [1, 0, 100], + ])("runs find_axis_length: x %s %s %s", (up, first, second) => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_x = 500; + firmwareConfig.body.movement_home_up_x = up; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("find_axis_length(\"x\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: first, y: 2, z: 3 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: second, y: 2, z: 3 }, + }); + }); + + it.each<[number, number, number]>([ + [0, 0, 100], + [1, 0, 100], + ])("runs find_axis_length: y %s %s %s", (up, first, second) => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_y = 500; + firmwareConfig.body.movement_home_up_y = up; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("find_axis_length(\"y\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: first, z: 3 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: second, z: 3 }, + }); + }); + + it.each<[number, number, number]>([ + [0, 0, 100], + [1, 0, -100], + ])("runs find_axis_length: z %s %s %s", (up, first, second) => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_z = 2500; + firmwareConfig.body.movement_home_up_z = up; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("find_axis_length(\"z\")"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 2, z: first }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 2, z: second }, + }); + }); + + it("runs toggle_pin", () => { + runDemoLuaCode("toggle_pin(5)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_TOGGLE_PIN, + payload: 5, + }); + }); + + it("runs write_pin", () => { + runDemoLuaCode("write_pin(5, \"digital\", 1)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, mode: "digital", value: 1 }, + }); + }); + + it("runs on", () => { + runDemoLuaCode("on(5)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, mode: "digital", value: 1 }, + }); + }); + + it("doesn't run when estopped", () => { + mockLocked = true; + runDemoLuaCode("on(5)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("runs off", () => { + runDemoLuaCode("off(5)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, mode: "digital", value: 0 }, + }); + }); + + it("runs safe_z", () => { + runDemoLuaCode("print(safe_z())"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs env", () => { + runDemoLuaCode("print(env(\"foo\"))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(""); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs soil_height", () => { + runDemoLuaCode("print(soil_height(0, 0))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("-500"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs get_device", () => { + const device = fakeDevice(); + device.body.mounted_tool_id = 1; + mockResources = buildResourceIndex([device]); + runDemoLuaCode("print(get_device(\"mounted_tool_id\"))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs get_device: undefined value", () => { + const device = fakeDevice(); + device.body.mounted_tool_id = undefined; + mockResources = buildResourceIndex([device]); + runDemoLuaCode("print(get_device(\"mounted_tool_id\"))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("false"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs update_device", () => { + const device = fakeDevice(); + device.body.mounted_tool_id = 0; + mockResources = buildResourceIndex([device]); + runDemoLuaCode("update_device{ mounted_tool_id = 1 }"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(0); + expect(info).not.toHaveBeenCalled(); + expect(edit).toHaveBeenCalledWith(device, { mounted_tool_id: 1 }); + expect(save).toHaveBeenCalledWith(device.uuid); + }); + + it("runs read_pin 63: 0", () => { + const device = fakeDevice(); + device.body.mounted_tool_id = 1; + mockResources = buildResourceIndex([device]); + runDemoLuaCode("print(read_pin(63))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs read_pin 63: 1", () => { + const device = fakeDevice(); + device.body.mounted_tool_id = 0; + mockResources = buildResourceIndex([device]); + runDemoLuaCode("print(read_pin(63))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs read_pin 5", () => { + mockResources = buildResourceIndex([]); + runDemoLuaCode("print(read_pin(5))"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs move_relative", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move_relative(1, 0, 0)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 2, y: 2, z: 3 }, + }); + }); + + it("runs move_relative: zero", () => { + setCurrent({ x: 0, y: 0, z: 0 }); + runDemoLuaCode("move_relative(0, 0, 0)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + }); + + it("runs move_absolute", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move_absolute(1, 0, 0)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 0 }, + }); + }); + + it("runs move_absolute: alternate syntax", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move_absolute{ x = 1, y = 0, z = 0 }"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 0 }, + }); + }); + + it("runs move_absolute: clamps positive", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_z = 2500; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move_absolute(0, 0, 1000)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 100 }, + }); + }); + + it("runs move_absolute: clamps negative", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_z = 2500; + firmwareConfig.body.movement_home_up_z = 1; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move_absolute(0, 0, -1000)"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: -100 }, + }); + }); + + it("runs move: y", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move{ y = 1 }"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 1, z: 3 }, + }); + }); + + it("runs move: x and z", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("move{ x = 0, z = 0 }"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 0 }, + }); + }); + + it("runs take_photo", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("take_photo()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Image", { + attachment_url: "http://localhost/soil.png", + created_at: expect.any(String), + meta: { + name: "demo.png", + x: 1, + y: 2, + z: 3, + }, + }); + }); + + it("runs calibrate_camera", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("calibrate_camera()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Image", { + attachment_url: "http://localhost/soil.png", + created_at: expect.any(String), + meta: { + name: "demo.png", + x: 1, + y: 2, + z: 3, + }, + }); + }); + + it("runs detect_weeds", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("detect_weeds()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Image", { + attachment_url: "http://localhost/soil.png", + created_at: expect.any(String), + meta: { + name: "demo.png", + x: 1, + y: 2, + z: 3, + }, + }); + expect(initSave).toHaveBeenCalledWith("Point", { + meta: { + color: "red", + created_by: "plant-detection", + }, + name: "Weed", + plant_stage: "pending", + pointer_type: "Weed", + radius: 50, + x: 1, + y: 2, + z: -500, + }); + }); + + it("runs measure_soil_height", () => { + setCurrent({ x: 1, y: 2, z: 3 }); + runDemoLuaCode("measure_soil_height()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("Image", { + attachment_url: "http://localhost/soil.png", + created_at: expect.any(String), + meta: { + name: "demo.png", + x: 1, + y: 2, + z: 3, + }, + }); + expect(initSave).toHaveBeenCalledWith("Point", { + meta: { + at_soil_level: "true", + }, + name: "Soil Height", + pointer_type: "GenericPointer", + radius: 0, + x: 1, + y: 2, + z: -500, + }); + }); + + it("runs emergency_lock", () => { + runDemoLuaCode("emergency_lock()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + }); + + it("runs emergency_unlock", () => { + runDemoLuaCode("emergency_unlock()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_ESTOP, + payload: false, + }); + }); + + it("allows emergency_unlock", () => { + mockLocked = true; + runDemoLuaCode("emergency_unlock()"); + jest.runAllTimers(); + expect(error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_ESTOP, + payload: false, + }); + }); + + it("runs non-implemented function", () => { + runDemoLuaCode("foo.bar.baz()"); + jest.runAllTimers(); + expect(info).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledWith("Log", { + message: "Lua function \"foo.bar.baz\" is not implemented.", + type: "error", + channels: ["undefined"], + verbosity: undefined, + x: 0, + y: 0, + z: 0, + }); + }); +}); + +describe("csToLua()", () => { + it("returns Lua", () => { + expect(csToLua({ kind: "emergency_lock", args: {} })) + .toEqual("emergency_lock()"); + }); +}); + +/** + * Lua functions available in the demo runner + * + * builtins/lib: + * [ y ] print + * [ y ] type + * [ y ] tostring + * [ y ] tonumber + * [ y ] pairs + * [ y ] ipairs + * [ y ] os.date + * [ y ] os.time + * [ y ] math. + * [ y ] table. + * [ y ] string. + * + * Other: + * [ y ] move_relative + * [ y ] round + * [ y ] angleRound + * [ y ] cropAmount + * [ y ] fwe + * [ y ] axis_overwrite + * [ y ] speed_overwrite + * [ y ] iso8601 + * [ y ] current_year + * [ y ] current_day + * + * FBOS: + * [ y ] variable + * [ ] auth_token + * [ y ] api + * [ ] base64.decode + * [ ] base64.encode + * [ y ] calibrate_camera + * [ ] check_position + * [ y ] complete_job + * [ ] coordinate + * [ y ] cs_eval + * [ y ] current_hour + * [ y ] current_minute + * [ y ] current_month + * [ y ] current_second + * [ y ] detect_weeds + * [ y ] dispense + * [ y ] emergency_lock + * [ y ] emergency_unlock + * [ y ] env + * [ ] fbos_version + * [ y ] find_axis_length + * [ y ] find_home + * [ ] firmware_version + * [ y ] garden_size + * [ ] gcode + * [ y ] get_curve + * [ y ] get_device + * [ ] get_fbos_config + * [ ] get_firmware_config + * [ y ] get_job + * [ y ] get_job_progress + * [ ] get_position + * [ y ] get_seed_tray_cell + * [ ] get_xyz + * [ y ] get_tool + * [ y ] get_plants + * [ y ] get_weeds + * [ y ] get_generic_points + * [ y ] get_group + * [ y ] go_to_home + * [ y ] grid + * [ y ] group + * [ ] http + * [ y ] inspect + * [ y ] json.decode + * [ y ] json.encode + * [ y ] measure_soil_height + * [ y ] mount_tool + * [ y ] dismount_tool + * [ y ] move_absolute + * [ y ] move + * [ ] new_sensor_reading + * [ y ] photo_grid + * [ y ] read_pin + * [ ] read_status + * [ y ] rpc + * [ y ] sequence + * [ y ] send_message + * [ y ] debug + * [ y ] toast + * [ y ] safe_z + * [ y ] set_job + * [ y ] set_job_progress + * [ ] set_pin_io_mode + * [ ] soft_stop + * [ y ] soil_height + * [ y ] sort + * [ ] take_photo_raw + * [ y ] take_photo + * [ y ] toggle_pin + * [ ] uart.open + * [ ] uart.list + * [ y ] update_device + * [ ] update_fbos_config + * [ ] update_firmware_config + * [ y ] utc + * [ ] local_time + * [ y ] to_unix + * [ y ] verify_tool + * [ y ] wait_ms + * [ y ] wait + * [ y ] water + * [ ] watch_pin + * [ y ] on + * [ y ] off + * [ y ] write_pin + */ diff --git a/frontend/demo/lua_runner/__tests__/lua_test.ts b/frontend/demo/lua_runner/__tests__/lua_test.ts new file mode 100644 index 0000000000..ece41ae297 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/lua_test.ts @@ -0,0 +1,7 @@ +import { LUA_HELPERS } from "../lua"; + +describe("LUA_HELPERS", () => { + it("returns lua code", () => { + expect(LUA_HELPERS.length).toBeGreaterThan(100); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/run_test.ts b/frontend/demo/lua_runner/__tests__/run_test.ts new file mode 100644 index 0000000000..b55ac633f0 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/run_test.ts @@ -0,0 +1,24 @@ +import { runLua } from "../run"; + +describe("runLua()", () => { + it("returns actions", () => { + const code = ` + move_absolute(1, 2, 3) + wait_ms(1000) + go_to_home("all") + move{ y = 1 } + `; + expect(runLua(0, code, [])).toEqual([ + { type: "move_absolute", args: [1, 2, 3] }, + { type: "wait_ms", args: [1000] }, + { type: "go_to_home", args: ["all"] }, + { + type: "_move", + args: [ + "[{\"kind\":\"axis_overwrite\",\"args\":{\"axis\":\"y\",\"" + + "axis_operand\":{\"kind\":\"numeric\",\"args\":{\"number\":1}}}}]", + ], + }, + ]); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/stubs_test.ts b/frontend/demo/lua_runner/__tests__/stubs_test.ts new file mode 100644 index 0000000000..f3b33fd427 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/stubs_test.ts @@ -0,0 +1,76 @@ +import { TaggedFbosConfig } from "farmbot"; +import { + fakeFbosConfig, + fakeFirmwareConfig, + fakeWebAppConfig, +} from "../../../__test_support__/fake_state/resources"; + +let mockFirmwareConfig = fakeFirmwareConfig(); +let mockWebAppConfig = fakeWebAppConfig(); +let mockFbosConfig: TaggedFbosConfig | undefined = fakeFbosConfig(); +jest.mock("../../../resources/getters", () => ({ + getFirmwareConfig: () => mockFirmwareConfig, + getWebAppConfig: () => mockWebAppConfig, + getFbosConfig: () => mockFbosConfig, +})); + +import { getDefaultAxisOrder, getGardenSize, getSafeZ } from "../stubs"; + +describe("getGardenSize()", () => { + it("gets garden size: axis lengths", () => { + mockFirmwareConfig = fakeFirmwareConfig(); + mockFirmwareConfig.body.movement_axis_nr_steps_x = 5000; + mockFirmwareConfig.body.movement_axis_nr_steps_y = 5000; + mockFirmwareConfig.body.movement_axis_nr_steps_z = 25000; + mockWebAppConfig = fakeWebAppConfig(); + mockWebAppConfig.body.map_size_x = 100; + mockWebAppConfig.body.map_size_y = 100; + expect(getGardenSize()).toEqual({ x: 1000, y: 1000, z: 1000 }); + }); + + it("gets garden size: map size", () => { + mockFirmwareConfig = fakeFirmwareConfig(); + mockFirmwareConfig.body.movement_axis_nr_steps_x = 0; + mockFirmwareConfig.body.movement_axis_nr_steps_y = 0; + mockFirmwareConfig.body.movement_axis_nr_steps_z = 0; + mockWebAppConfig = fakeWebAppConfig(); + mockWebAppConfig.body.map_size_x = 100; + mockWebAppConfig.body.map_size_y = 100; + expect(getGardenSize()).toEqual({ x: 100, y: 100, z: 500 }); + }); +}); + +describe("getSafeZ()", () => { + it("gets zero", () => { + mockFbosConfig = fakeFbosConfig(); + mockFbosConfig.body.safe_height = undefined; + expect(getSafeZ()).toEqual(0); + }); + + it("gets height", () => { + mockFbosConfig = fakeFbosConfig(); + mockFbosConfig.body.safe_height = -200; + expect(getSafeZ()).toEqual(-200); + }); +}); + +describe("getDefaultAxisOrder()", () => { + it("handles undefined", () => { + mockFbosConfig = undefined; + expect(getDefaultAxisOrder()).toEqual([]); + }); + + it("returns safe_z", () => { + mockFbosConfig = fakeFbosConfig(); + mockFbosConfig.body.default_axis_order = "safe_z"; + expect(getDefaultAxisOrder()).toEqual([{ kind: "safe_z", args: {} }]); + }); + + it("returns axis_order", () => { + mockFbosConfig = fakeFbosConfig(); + mockFbosConfig.body.default_axis_order = "xyz;high"; + expect(getDefaultAxisOrder()).toEqual([ + { kind: "axis_order", args: { grouping: "xyz", route: "high" } }, + ]); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/util_test.ts b/frontend/demo/lua_runner/__tests__/util_test.ts new file mode 100644 index 0000000000..da35b86f4c --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/util_test.ts @@ -0,0 +1,316 @@ +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { + fakePeripheral, + fakePlant, + fakePoint, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ resources: mockResources }), + }, +})); + +import { csToLua, filterPoint } from "../util"; +import { + EmergencyLock, + EmergencyUnlock, + ExecuteScript, + FindHome, + Home, + Lua, + Move, + MoveAbsolute, + MoveRelative, + PlantStage, + SendMessage, + SequenceBodyItem, + TakePhoto, + TogglePin, + Wait, + WritePin, +} from "farmbot"; + +describe("csToLua()", () => { + it("converts celery script to lua: lock", () => { + const command: EmergencyLock = { kind: "emergency_lock", args: {} }; + expect(csToLua(command)).toEqual("emergency_lock()"); + }); + + it("converts celery script to lua: unlock", () => { + const command: EmergencyUnlock = { kind: "emergency_unlock", args: {} }; + expect(csToLua(command)).toEqual("emergency_unlock()"); + }); + + it("converts celery script to lua: find_home", () => { + const command: FindHome = { + kind: "find_home", + args: { axis: "x", speed: 100 }, + }; + expect(csToLua(command)).toEqual("find_home(\"x\")"); + }); + + it("converts celery script to lua: home", () => { + const command: Home = { kind: "home", args: { axis: "x", speed: 100 } }; + expect(csToLua(command)).toEqual("go_to_home(\"x\")"); + }); + + it("converts celery script to lua: wait", () => { + const command: Wait = { kind: "wait", args: { milliseconds: 1000 } }; + expect(csToLua(command)).toEqual("wait(1000)"); + }); + + it("converts celery script to lua: send_message", () => { + const command: SendMessage = { + kind: "send_message", + args: { message: "text", message_type: "info" }, + }; + expect(csToLua(command)).toEqual("send_message(\"info\", \"text\")"); + }); + + it("converts celery script to lua: take_photo", () => { + const command: TakePhoto = { kind: "take_photo", args: {} }; + expect(csToLua(command)).toEqual("take_photo()"); + }); + + it("converts celery script to lua: execute_script: plant-detection", () => { + const command: ExecuteScript = { + kind: "execute_script", + args: { label: "plant-detection" }, + }; + expect(csToLua(command)).toEqual("detect_weeds()"); + }); + + it("converts celery script to lua: execute_script Measure Soil Height", () => { + const command: ExecuteScript = { + kind: "execute_script", + args: { label: "Measure Soil Height" }, + }; + expect(csToLua(command)).toEqual("measure_soil_height()"); + }); + + it("converts celery script to lua: execute_script other", () => { + const command: ExecuteScript = { + kind: "execute_script", + args: { label: "other" }, + }; + expect(csToLua(command)).toEqual(""); + }); + + it("converts celery script to lua: move_relative", () => { + const command: MoveRelative = { + kind: "move_relative", + args: { x: 1, y: 2, z: 3, speed: 100 }, + }; + expect(csToLua(command)).toEqual("move_relative(1, 2, 3)"); + }); + + it("converts celery script to lua: move_absolute coordinate", () => { + const command: MoveAbsolute = { + kind: "move_absolute", + args: { + location: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, + offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }, + speed: 100, + }, + }; + expect(csToLua(command)).toEqual("move_absolute(1, 2, 3)"); + }); + + it("converts celery script to lua: move_absolute other", () => { + const command: MoveAbsolute = { + kind: "move_absolute", + args: { + location: { kind: "identifier", args: { label: "" } }, + offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }, + speed: 100, + }, + }; + expect(csToLua(command)).toEqual( + "toast(\"move_absolute identifier is not implemented\", \"error\")"); + }); + + it("converts celery script to lua: move", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "y", + axis_operand: { kind: "numeric", args: { number: 1 } }, + }, + }, + ], + }; + expect(csToLua(command)).toEqual( + "_move(\"[{\\\"kind\\\":\\\"axis_overwrite\\\",\\\"args\\\":{" + + "\\\"axis\\\":\\\"y\\\",\\\"axis_operand\\\":{\\\"kind\\\":\\\"" + + "numeric\\\",\\\"args\\\":{\\\"number\\\":1}}}}]\")"); + }); + + it("converts celery script to lua: move no body", () => { + const command: Move = { kind: "move", args: {} }; + expect(csToLua(command)).toEqual("_move(\"[]\")"); + }); + + it("converts celery script to lua: write_pin", () => { + const command: WritePin = { + kind: "write_pin", + args: { pin_number: 1, pin_mode: 0, pin_value: 1 }, + }; + expect(csToLua(command)).toEqual("write_pin(1, \"digital\", 1)"); + }); + + it("converts celery script to lua: peripheral", () => { + const peripheral = fakePeripheral(); + peripheral.body.id = 1; + peripheral.body.pin = 2; + mockResources = buildResourceIndex([peripheral]); + const command: WritePin = { + kind: "write_pin", + args: { + pin_number: { + kind: "named_pin", + args: { pin_id: 1, pin_type: "Peripheral" }, + }, + pin_mode: 0, + pin_value: 1, + }, + }; + expect(csToLua(command)).toEqual("write_pin(2, \"digital\", 1)"); + }); + + it("converts celery script to lua: missing peripheral", () => { + mockResources = buildResourceIndex([]); + const command: WritePin = { + kind: "write_pin", + args: { + pin_number: { + kind: "named_pin", + args: { pin_id: 1, pin_type: "Peripheral" }, + }, + pin_mode: 0, + pin_value: 1, + }, + }; + expect(csToLua(command)).toEqual(""); + }); + + it("converts celery script to lua: write_pin analog", () => { + const command: WritePin = { + kind: "write_pin", + args: { pin_number: 1, pin_mode: 1, pin_value: 1 }, + }; + expect(csToLua(command)).toEqual("write_pin(1, \"analog\", 1)"); + }); + + it("converts celery script to lua: toggle_pin", () => { + const command: TogglePin = { + kind: "toggle_pin", + args: { pin_number: 1 }, + }; + expect(csToLua(command)).toEqual("toggle_pin(1)"); + }); + + it("converts celery script to lua: lua", () => { + const command: Lua = { kind: "lua", args: { lua: "print(\"lua\")" } }; + expect(csToLua(command)).toEqual("print(\"lua\")"); + }); + + it("converts celery script to lua: not implemented", () => { + const command = { kind: "nope", args: {} } as unknown as SequenceBodyItem; + expect(csToLua(command)).toEqual( + "toast(\"celeryscript nope is not implemented\", \"error\")"); + }); +}); + +describe("filterPoint()", () => { + it.each<[string, PlantStage, boolean]>([ + ["yes", "planted", true], + ["no", "planned", false], + ])("filters point: stage filter %s", (_label, value, bool) => { + const p = fakePlant().body; + p.plant_stage = value; + expect(filterPoint({ plant_stage: "planted" }, undefined)(p)).toEqual(bool); + }); + + it("filters point: no default stage filter", () => { + const p = fakePlant().body; + expect(filterPoint({}, undefined)(p)).toBeTruthy(); + }); + + it("filters point: with default stage filter", () => { + const p = fakePlant().body; + p.plant_stage = "planted"; + expect(filterPoint({}, "planted")(p)).toBeTruthy(); + }); + + it.each<[string, string, boolean]>([ + ["yes", "mint", true], + ["no", "strawberry", false], + ])("filters point: slug filter %s", (_label, value, bool) => { + const p = fakePlant().body; + p.openfarm_slug = value; + expect(filterPoint({ openfarm_slug: "mint" }, undefined)(p)).toEqual(bool); + }); + + it("filters point: age filter undefined", () => { + const p = fakePlant().body; + p.planted_at = undefined; + expect(filterPoint({ min_age: 0, max_age: 1 }, undefined)(p)).toBeTruthy(); + }); + + it.each<[string, string, boolean]>([ + ["yes", "2018-01-23T05:00:00.000Z", true], + ["no", "2999-01-23T05:00:00.000Z", false], + ])("filters point: age filter %s", (_label, value, bool) => { + const p = fakePlant().body; + p.planted_at = value; + expect(filterPoint({ min_age: 10 }, undefined)(p)).toEqual(bool); + }); + + it.each<[string, number, boolean]>([ + ["yes", 100, true], + ["no", 0, false], + ])("filters point: radius filter %s", (_label, value, bool) => { + const p = fakePlant().body; + p.radius = value; + expect(filterPoint({ min_radius: 10, max_radius: 1000 }, undefined)(p)) + .toEqual(bool); + }); + + it.each<[string, string, boolean]>([ + ["yes", "red", true], + ["no", "green", false], + ])("filters point: color filter %s", (_label, value, bool) => { + const p = fakePoint().body; + p.meta.color = value; + expect(filterPoint({ color: "red" }, undefined)(p)).toEqual(bool); + }); + + it.each<[string, string, boolean]>([ + ["yes", "true", true], + ["no", "false", false], + ])("filters point: soil level filter true %s", (_label, value, bool) => { + const p = fakePoint().body; + p.meta.at_soil_level = value; + expect(filterPoint({ at_soil_level: "true" }, undefined)(p)).toEqual(bool); + }); + + it("filters point: soil level filter nil", () => { + const p = fakePoint().body; + p.meta.at_soil_level = undefined; + expect(filterPoint({ at_soil_level: "false" }, undefined)(p)).toBeTruthy(); + }); + + it("filters point: soil level filter false", () => { + const p = fakePoint().body; + p.meta.at_soil_level = "false"; + expect(filterPoint({ at_soil_level: "false" }, undefined)(p)).toBeTruthy(); + }); +}); diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts new file mode 100644 index 0000000000..b93fa773e5 --- /dev/null +++ b/frontend/demo/lua_runner/actions.ts @@ -0,0 +1,439 @@ +import { + ALLOWED_CHANNEL_NAMES, + ALLOWED_MESSAGE_TYPES, + MoveBodyItem, + ParameterApplication, + PercentageProgress, +} from "farmbot"; +import { info } from "../../toast/toast"; +import { store } from "../../redux/store"; +import { Actions } from "../../constants"; +import { TOAST_OPTIONS } from "../../toast/constants"; +import { Action, XyzNumber } from "./interfaces"; +import { edit, init, initSave, save } from "../../api/crud"; +import { getDeviceAccountSettings } from "../../resources/selectors"; +import { UnknownAction } from "redux"; +import { getFirmwareSettings, getGardenSize } from "./stubs"; +import { clamp, random } from "lodash"; +import { validBotLocationData } from "../../util/location"; +import { Point } from "farmbot/dist/resources/api_resources"; +import { calculateMove } from "./calculate_move"; +import { t } from "../../i18next_wrapper"; +import { API } from "../../api"; + +const almostEqual = (a: XyzNumber, b: XyzNumber) => { + const epsilon = 0.01; + return Math.abs(a.x - b.x) < epsilon && + Math.abs(a.y - b.y) < epsilon && + Math.abs(a.z - b.z) < epsilon; +}; + +const movementChunks = ( + current: XyzNumber, + target: XyzNumber, + mmPerTimeStep: number, +): XyzNumber[] => { + const dx = target.x - current.x; + const dy = target.y - current.y; + const dz = target.z - current.z; + + const length = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (length === 0) { return [target]; } + const direction = { + x: dx / length, + y: dy / length, + z: dz / length, + }; + const steps = localStorage.getItem("DISABLE_CHUNKING") === "true" + ? 0 + : Math.floor(length / mmPerTimeStep); + const chunks: XyzNumber[] = []; + for (let i = 1; i <= steps; i++) { + const step = { + x: current.x + direction.x * mmPerTimeStep * i, + y: current.y + direction.y * mmPerTimeStep * i, + z: current.z + direction.z * mmPerTimeStep * i, + }; + chunks.push(step); + } + if (chunks.length === 0 || !almostEqual(chunks[chunks.length - 1], target)) { + chunks.push(target); + } + return chunks; +}; + +const clampTarget = (target: XyzNumber): XyzNumber => { + const firmwareConfig = getFirmwareSettings(); + const bounds = getGardenSize(); + const clamped = { + x: clamp(target.x, 0, bounds.x), + y: clamp(target.y, 0, bounds.y), + z: firmwareConfig.movement_home_up_z + ? clamp(target.z, -bounds.z, 0) + : clamp(target.z, 0, bounds.z), + }; + return clamped; +}; + +const current = { + x: 0, + y: 0, + z: 0, +}; + +export const setCurrent = (position: XyzNumber) => { + current.x = position.x; + current.y = position.y; + current.z = position.z; +}; + +export const expandActions = ( + actions: Action[], + variables: ParameterApplication[] | undefined, + stashedCurrentPosition?: XyzNumber, +): Action[] => { + const expanded: Action[] = []; + const timeStepMs = parseInt(localStorage.getItem("timeStepMs") || "250"); + const mmPerSecond = parseInt(localStorage.getItem("mmPerSecond") || "500"); + const mmPerTimeStep = (mmPerSecond * timeStepMs) / 1000; + const addPosition = (position: XyzNumber) => { + expanded.push({ + type: "wait_ms", + args: [timeStepMs], + }); + expanded.push({ + type: "expanded_move_absolute", + args: [position.x, position.y, position.z], + }); + }; + // eslint-disable-next-line complexity + actions.map(action => { + switch (action.type) { + case "move_absolute": + const moveAbsoluteTarget = clampTarget({ + x: action.args[0] as number, + y: action.args[1] as number, + z: action.args[2] as number, + }); + movementChunks(current, moveAbsoluteTarget, mmPerTimeStep).map(addPosition); + setCurrent(moveAbsoluteTarget); + break; + case "move_relative": + const moveRelativeTarget = clampTarget({ + x: current.x + (action.args[0] as number), + y: current.y + (action.args[1] as number), + z: current.z + (action.args[2] as number), + }); + movementChunks(current, moveRelativeTarget, mmPerTimeStep).map(addPosition); + setCurrent(moveRelativeTarget); + break; + case "_move": + const moveItems = JSON.parse("" + action.args[0]) as MoveBodyItem[]; + const { moves, warnings } = calculateMove(moveItems, current, variables); + warnings.length > 0 && expanded.push({ + type: "send_message", + args: [ + "warn", + `not yet supported: ${warnings.join(", ")}`, + "", + JSON.stringify(current), + ], + }); + const actualMoveTargets = moves.map(clampTarget); + actualMoveTargets.map(actualMoveTarget => { + movementChunks(current, actualMoveTarget, mmPerTimeStep).map(addPosition); + setCurrent(actualMoveTarget); + }); + break; + case "send_message": + action.args[3] = JSON.stringify(current); + expanded.push({ type: "send_message", args: action.args }); + break; + case "take_photo": + case "calibrate_camera": + case "detect_weeds": + case "measure_soil_height": + const MSGS = { + "take_photo": "Taking photo", + "calibrate_camera": "Calibrating camera", + "detect_weeds": "Running weed detector", + "measure_soil_height": "Executing Measure Soil Height", + }; + const DELAYS = { + "take_photo": 5, + "calibrate_camera": 15, + "detect_weeds": 15, + "measure_soil_height": 15, + }; + expanded.push({ + type: "send_message", + args: [ + "info", + MSGS[action.type], + "", + JSON.stringify(current), + 3, + ], + }); + expanded.push({ + type: "wait_ms", + args: [(DELAYS[action.type] - 3) * 1000], + }); + expanded.push({ + type: "take_photo", + args: [current.x, current.y, current.z], + }); + expanded.push({ + type: "send_message", + args: [ + "info", + "Uploaded image:", + "", + JSON.stringify(current), + 3, + ], + }); + if (action.type === "measure_soil_height") { + const body: Point = { + name: "Soil Height", + pointer_type: "GenericPointer", + x: current.x, + y: current.y, + z: -500 + random(-10, 10), + meta: { at_soil_level: "true" }, + radius: 0, + }; + const point = JSON.stringify(body); + expanded.push({ type: "create_point", args: [point] }); + } + if (action.type === "detect_weeds") { + const body: Point = { + name: "Weed", + pointer_type: "Weed", + x: current.x, + y: current.y, + z: -500, + meta: { color: "red", created_by: "plant-detection" }, + radius: 50, + plant_stage: "pending", + }; + const point = JSON.stringify(body); + expanded.push({ type: "create_point", args: [point] }); + } + break; + case "find_home": + case "go_to_home": + const axisInput = action.args[0] as string; + const axes = axisInput == "all" ? ["z", "y", "x"] : [axisInput]; + axes.map(axis => { + const homeTarget = { + x: axis == "x" ? 0 : current.x, + y: axis == "y" ? 0 : current.y, + z: axis == "z" ? 0 : current.z, + }; + movementChunks(current, homeTarget, mmPerTimeStep).map(addPosition); + setCurrent(homeTarget); + }); + break; + default: + expanded.push(action); + break; + } + }); + if (stashedCurrentPosition) { + setCurrent(stashedCurrentPosition); + } + return expanded; +}; + +interface Scheduled { + func(): void; + timestamp: number; +} +const pending: Scheduled[] = []; +let latestActionMs = Date.now(); +let currentTimer: ReturnType | undefined = undefined; + +export const eStop = () => { + latestActionMs = 0; + pending.length = 0; + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + const { position } = validBotLocationData( + store.getState().bot.hardware.location_data); + current.x = position.x as number; + current.y = position.y as number; + current.z = position.z as number; +}; + +export const runActions = ( + actions: Action[], +) => { + let delay = 0; + let notified = false; + actions.map(action => { + // eslint-disable-next-line complexity + const getFunc = () => { + const estopped = store.getState().bot.hardware.informational_settings.locked; + if (estopped && action.type !== "emergency_unlock") { + if (!notified) { + info(t("Command not available while locked."), { + ...TOAST_OPTIONS().error, + title: t("Emergency stop active"), + }); + notified = true; + } + return; + } + switch (action.type) { + case "wait_ms": + const ms = action.args[0] as number; + delay += ms; + return undefined; + case "send_message": + const type = "" + action.args[0]; + const msg = "" + action.args[1]; + const channelsStr = "" + action.args[2]; + const channels = channelsStr.split(",") as ALLOWED_CHANNEL_NAMES[]; + const logPosition = JSON.parse("" + action.args[3]) as XyzNumber; + const verbosity = action.args[4] as number; + return () => { + if (channels.includes("toast")) { + info(msg, TOAST_OPTIONS()[type]); + } + const initAction = init("Log", { + message: msg, + type: type as ALLOWED_MESSAGE_TYPES, + ...logPosition, + channels, + verbosity, + }); + store.dispatch(initAction as unknown as UnknownAction); + setTimeout(() => { + store.dispatch( + save(initAction.payload.uuid) as unknown as UnknownAction); + }, 20000); + }; + case "print": + return () => { + console.log(action.args[0]); + }; + case "take_photo": + return () => { + const timestamp = (new Date()).toISOString(); + store.dispatch(initSave("Image", { + attachment_url: API.current.baseUrl + "/soil.png", + created_at: timestamp, + meta: { + x: action.args[0] as number, + y: action.args[1] as number, + z: action.args[2] as number, + name: "demo.png", + }, + }) as unknown as UnknownAction); + }; + case "emergency_lock": + return eStop; + case "emergency_unlock": + return () => { + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: false, + }); + }; + case "expanded_move_absolute": + const x = action.args[0] as number; + const y = action.args[1] as number; + const z = action.args[2] as number; + const position = { x, y, z }; + return () => { + store.dispatch({ + type: Actions.DEMO_SET_POSITION, + payload: position, + }); + }; + case "toggle_pin": + return () => { + store.dispatch({ + type: Actions.DEMO_TOGGLE_PIN, + payload: action.args[0] as number, + }); + }; + case "write_pin": + const pin = action.args[0] as number; + const mode = action.args[1] as string; + const value = action.args[2] as number; + return () => { + store.dispatch({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin, mode, value }, + }); + }; + case "set_job_progress": + const job = "" + action.args[0]; + const percent = action.args[1] as number; + const status = action.args[2]; + const time = action.args[3]; + const progress: PercentageProgress = { + unit: "percent", + percent: percent || 0, + status: (status || "Working") as "working", + type: "unknown", + file_type: "", + updated_at: (new Date()).valueOf() / 1000, + time: (status == "Complete" ? undefined : time) as string, + }; + return () => { + store.dispatch({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: [job, progress], + }); + }; + case "create_point": + const point = JSON.parse("" + action.args[0]) as Point; + point.meta = point.meta || {}; + return () => { + store.dispatch(initSave("Point", point) as unknown as UnknownAction); + }; + case "update_device": + return () => { + const device = + getDeviceAccountSettings(store.getState().resources.index); + store.dispatch(edit(device, { + mounted_tool_id: action.args[1] as number, + }) as unknown as UnknownAction); + store.dispatch(save(device.uuid) as unknown as UnknownAction); + }; + } + }; + const func = getFunc(); + if (func) { + latestActionMs = Math.max(latestActionMs, Date.now()) + delay; + const item = { func, timestamp: latestActionMs }; + pending.push(item); + delay = 0; + runNext(); + } + }); +}; + +const runNext = () => { + if (currentTimer || pending.length === 0) { + return; + } + const next = pending[0]; + const delay = Math.max(next.timestamp - Date.now(), 0); + + currentTimer = setTimeout(() => { + currentTimer = undefined; + const task = pending.shift(); + task?.func(); + store.dispatch({ + type: Actions.DEMO_SET_QUEUE_LENGTH, + payload: pending.length, + }); + runNext(); + }, delay); +}; diff --git a/frontend/demo/lua_runner/calculate_move.ts b/frontend/demo/lua_runner/calculate_move.ts new file mode 100644 index 0000000000..a92a79e945 --- /dev/null +++ b/frontend/demo/lua_runner/calculate_move.ts @@ -0,0 +1,221 @@ +import { + ALLOWED_GROUPING, + ALLOWED_ROUTE, + Identifier, + MoveBodyItem, + ParameterApplication, + Xyz, +} from "farmbot"; +import { store } from "../../redux/store"; +import { XyzNumber } from "./interfaces"; +import { + maybeFindPointById, + maybeFindSlotByToolId, +} from "../../resources/selectors"; +import { getDefaultAxisOrder, getSafeZ, getSoilHeight } from "./stubs"; +import { clone } from "lodash"; + +export const addDefaults = (body: MoveBodyItem[]): MoveBodyItem[] => { + if (body.some(item => item.kind === "axis_order")) { + return body; + } + return body.concat(getDefaultAxisOrder()); +}; + +export const calculateMove = ( + body: MoveBodyItem[] | undefined, + current: XyzNumber, + variables: ParameterApplication[] | undefined, +): { moves: XyzNumber[], warnings: string[] } => { + const pos = clone(current); + const warnings: string[] = []; + const moveBodyItems = addDefaults(body || []); + // eslint-disable-next-line complexity + moveBodyItems.map(item => { + switch (item.kind) { + case "axis_addition": + switch (item.args.axis_operand.kind) { + case "numeric": + if (item.args.axis == "all") { + pos.x += item.args.axis_operand.args.number; + pos.y += item.args.axis_operand.args.number; + pos.z += item.args.axis_operand.args.number; + } else { + pos[item.args.axis] += item.args.axis_operand.args.number; + } + break; + case "coordinate": + if (item.args.axis == "all") { + pos.x += item.args.axis_operand.args.x; + pos.y += item.args.axis_operand.args.y; + pos.z += item.args.axis_operand.args.z; + } else { + pos[item.args.axis] += item.args.axis_operand.args[item.args.axis]; + } + break; + default: + warnings.push( + `axis_addition axis_operand kind: ${item.args.axis_operand.kind}`); + break; + } + return; + case "axis_overwrite": + switch (item.args.axis_operand.kind) { + case "numeric": + if (item.args.axis == "all") { + pos.x = item.args.axis_operand.args.number; + pos.y = item.args.axis_operand.args.number; + pos.z = item.args.axis_operand.args.number; + } else { + pos[item.args.axis] = item.args.axis_operand.args.number; + } + break; + case "coordinate": + if (item.args.axis == "all") { + pos.x = item.args.axis_operand.args.x; + pos.y = item.args.axis_operand.args.y; + pos.z = item.args.axis_operand.args.z; + } else { + pos[item.args.axis] = item.args.axis_operand.args[item.args.axis]; + } + break; + case "tool": + const toolSlot = maybeFindSlotByToolId( + store.getState().resources.index, + item.args.axis_operand.args.tool_id); + if (!toolSlot) { + break; + } + const toolSlotBody = clone(toolSlot.body); + if (toolSlotBody.gantry_mounted) { toolSlotBody.x = pos.x; } + if (item.args.axis == "all") { + pos.x = toolSlotBody.x; + pos.y = toolSlotBody.y; + pos.z = toolSlotBody.z; + } else { + pos[item.args.axis] = toolSlotBody[item.args.axis]; + } + break; + case "identifier": + const location = (variables || []).filter(v => { + const identifier = item.args.axis_operand as Identifier; + return v.args.label == identifier.args.label; + }) + .map(v => v.args.data_value)[0]; + if (location?.kind == "coordinate") { + pos.x = location.args.x; + pos.y = location.args.y; + pos.z = location.args.z; + } else if (location?.kind == "point") { + const point = maybeFindPointById( + store.getState().resources.index, + location.args.pointer_id); + if (!point) { break; } + pos.x = point.body.x; + pos.y = point.body.y; + pos.z = point.body.z; + } else { + warnings.push(`identifier location kind: ${location?.kind}`); + } + break; + case "special_value": + if (item.args.axis_operand.args.label == "soil_height" + && item.args.axis == "z") { + pos.z = getSoilHeight(pos.x, pos.y); + } else if (item.args.axis_operand.args.label == "safe_height" + && item.args.axis == "z") { + pos.z = getSafeZ(); + } else { + warnings.push( + `special_value label: ${item.args.axis_operand.args.label}`); + } + break; + default: + warnings.push( + `axis_overwrite axis_operand kind: ${item.args.axis_operand.kind}`); + break; + } + return; + case "speed_overwrite": + return; + case "safe_z": + return; + case "axis_order": + return; + default: + warnings.push(`item kind: ${(item as MoveBodyItem).kind}`); + return; + } + }); + if (moveBodyItems.some(item => item.kind === "safe_z")) { + const safeZ = getSafeZ(); + return { + moves: [ + { x: current.x, y: current.y, z: safeZ }, + { x: pos.x, y: pos.y, z: safeZ }, + pos, + ], + warnings, + }; + } + const axisOrderItems = moveBodyItems.filter(item => item.kind === "axis_order"); + if (axisOrderItems.length > 0) { + const { grouping, route } = axisOrderItems[0].args; + const moves = generateMoves(grouping, route, current, pos); + return { moves, warnings }; + } + return { moves: [pos], warnings }; +}; + +const generateMoves = ( + grouping: ALLOWED_GROUPING, + route: ALLOWED_ROUTE, + current: XyzNumber, + target: XyzNumber, +) => { + const axes: Xyz[] = ["x", "y", "z"]; + const zGoingUp = Math.abs(target.z) < Math.abs(current.z); + const groupsInput: string[] = grouping.split(","); + const isZFirst = (groups: string[]): boolean => + !groups.join("").includes("z") || groups[0].includes("z"); + const zFirst = (groupsArg: string[]): string[] => { + const groups = clone(groupsArg); + const idx = groups.findIndex(s => s.includes("z")); + if (idx > 0) { + const [group] = groups.splice(idx, 1); + groups.unshift(group); + } + return groups; + }; + const reverse = (groupsArg: string[]): string[] => clone(groupsArg).reverse(); + const isOrderOk = (groups: string[]): boolean => { + switch (route) { + case "high": + return isZFirst(zGoingUp ? groups : reverse(groups)); + case "low": + return isZFirst(zGoingUp ? reverse(groups) : groups); + default: + return true; + } + }; + const reorder = (groups: string[]): string[] => { + if (isOrderOk(groups)) { return groups; } + if (isOrderOk(reverse(groups))) { return reverse(groups); } + if (isOrderOk(zFirst(groups))) { return zFirst(groups); } + return reverse(zFirst(groups)); + }; + const moves: XyzNumber[] = []; + let lastState = { ...current }; + reorder(groupsInput).map(group => { + const normalized = group.split("").sort().join(""); + const movement = { ...lastState }; + axes.map(axis => { + if (normalized.includes(axis)) { + movement[axis] = target[axis]; + } + }); + moves.push(movement); + lastState = movement; + }); + return moves; +}; diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts new file mode 100644 index 0000000000..394742ed0d --- /dev/null +++ b/frontend/demo/lua_runner/index.ts @@ -0,0 +1,89 @@ +import { findSequenceById } from "../../resources/selectors"; +import { ResourceIndex } from "../../resources/interfaces"; +import { ParameterApplication, Point, SequenceBodyItem } from "farmbot"; +import { runLua } from "./run"; +import { expandActions, runActions } from "./actions"; +import { Action } from "./interfaces"; +import { csToLua } from "./util"; +import { error } from "../../toast/toast"; +import { getGroupPoints } from "./stubs"; + +export const runDemoLuaCode = (luaCode: string) => { + const actions = runLua(0, luaCode, []); + runActions(expandActions(actions, [])); +}; + +export const collectDemoSequenceActions = ( + depth: number, + resources: ResourceIndex, + sequenceId: number, + bodyVariables: ParameterApplication[] | undefined, +): Action[] => { + console.log(`Call depth: ${depth}`); + if (depth > 100) { + error("Maximum call depth exceeded."); + return []; + } + const sequence = findSequenceById(resources, sequenceId); + const varData = resources.sequenceMetas[sequence.uuid]; + const sequenceVariables = Object.values(varData || {}) + .map(v => v?.celeryNode) + .filter(v => v?.kind == "variable_declaration") + .filter(v => !bodyVariables?.map(v => v.args.label).includes(v.args.label)) + .map(v => ({ + kind: "parameter_application", + args: v.args, + } as ParameterApplication)); + const variables = [...sequenceVariables, ...(bodyVariables || [])]; + const actions: Action[] = []; + const firstVarArgs = variables[0]?.args; + if (firstVarArgs?.data_value.kind == "point_group") { + const variableLabel = firstVarArgs.label; + const groupId = firstVarArgs.data_value.args.point_group_id; + getGroupPoints(resources, groupId).map(p => { + const pointValue: Point = { + kind: "point", args: { + pointer_type: p.body.pointer_type, + pointer_id: p.body.id as number, + } + }; + const pointVariables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { label: variableLabel, data_value: pointValue } + }]; + const loopSeqActions = collectDemoSequenceActions( + depth + 1, + resources, + sequence.body.id as number, + pointVariables); + actions.push(...expandActions(loopSeqActions, pointVariables)); + }); + return actions; + } + (sequence.body.body as SequenceBodyItem[]).map(step => { + if (step.kind == "execute") { + const seqActions = collectDemoSequenceActions( + depth + 1, + resources, + step.args.sequence_id, + step.body); + actions.push(...seqActions); + } else { + const lua = step.kind === "lua" ? step.args.lua : csToLua(step); + const stepActions = runLua(depth, lua, variables); + actions.push(...stepActions); + } + }); + return actions; +}; + +export const runDemoSequence = ( + resources: ResourceIndex, + sequenceId: number, + variables: ParameterApplication[] | undefined, +) => { + const actions = collectDemoSequenceActions(0, resources, sequenceId, variables); + runActions(expandActions(actions, variables)); +}; + +export { csToLua }; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts new file mode 100644 index 0000000000..a533ab0616 --- /dev/null +++ b/frontend/demo/lua_runner/interfaces.ts @@ -0,0 +1,29 @@ +import { Xyz } from "farmbot"; + +export interface Action { + type: + | "move_absolute" + | "expanded_move_absolute" + | "move_relative" + | "move" + | "_move" + | "toggle_pin" + | "emergency_lock" + | "emergency_unlock" + | "find_home" + | "go_to_home" + | "send_message" + | "take_photo" + | "calibrate_camera" + | "detect_weeds" + | "measure_soil_height" + | "update_device" + | "create_point" + | "print" + | "wait_ms" + | "write_pin" + | "set_job_progress"; + args: (number | string | undefined)[]; +} + +export type XyzNumber = Record; diff --git a/frontend/demo/lua_runner/lua.ts b/frontend/demo/lua_runner/lua.ts new file mode 100644 index 0000000000..9c3b244b43 --- /dev/null +++ b/frontend/demo/lua_runner/lua.ts @@ -0,0 +1,884 @@ +/* eslint-disable max-len */ + +const FROM_FBOS = ` +function grid(params) + local x_point_count = params.grid_points.x + local y_point_count = params.grid_points.y + local z_point_count = params.grid_points.z + local x_grid_max_index = x_point_count - 1 + local y_grid_max_index = y_point_count - 1 + local z_grid_max_index = z_point_count - 1 + local start_time = os.time() * 1000 + + params.start = params.start or { x = 0, y = 0, z = 0 } + params.offset = params.offset or { x = 0, y = 0, z = 0 } + + local x = function(x_index) + return (params.start.x + (params.spacing.x * x_index) - params.offset.x) + end + local y = function(y_index) + return (params.start.y + (params.spacing.y * y_index) - params.offset.y) + end + local z = function(z_index) + return (params.start.z + (params.spacing.z * z_index) - params.offset.z) + end + + local grid_max_x = x(x_grid_max_index) + local grid_max_y = y(y_grid_max_index) + local grid_max_z = z(z_grid_max_index) + local x_max = garden_size().x + local y_max = garden_size().y + local z_max = garden_size().z + + local size_exceeded = "" + if x_max > 0 and grid_max_x > x_max then + size_exceeded = size_exceeded .. math.floor(grid_max_x) .. "mm exceeds " .. x_max .. "mm x-axis length. " + end + if y_max > 0 and grid_max_y > y_max then + size_exceeded = size_exceeded .. math.floor(grid_max_y) .. "mm exceeds " .. y_max .. "mm y-axis length. " + end + if z_max > 0 and grid_max_z > z_max then + size_exceeded = size_exceeded .. math.floor(grid_max_z) .. "mm exceeds " .. z_max .. "mm z-axis length. " + end + + if not params.ignore_empty and (x_point_count <= 0 or y_point_count <= 0 or z_point_count <= 0) then + toast("Number of points must be greater than 0 for all three axes", "error") + return + elseif not params.ignore_bounds and #size_exceeded > 0 then + toast("Grid must not exceed the **AXIS LENGTH** for any axes: " .. size_exceeded, "error") + return + end + + local each = function(callback) + local count = 0 + for z_grid_index = 0, z_grid_max_index do + for x_grid_index = 0, x_grid_max_index do + for y_grid_index = 0, y_grid_max_index do + count = count + 1 + local y_grid_index_var + if (x_grid_index % 2) == 0 then + y_grid_index_var = y_grid_index + else + y_grid_index_var = y_grid_max_index - y_grid_index + end + callback({ + x = x(x_grid_index), + y = y(y_grid_index_var), + z = z(z_grid_index), + count = count, + }) + end + end + end + end + + return { + total = x_point_count * y_point_count * z_point_count, + each = each, + } +end + +function round(n) return math.floor(n + 0.5) end + +function angleRound(angle) + local remainder = math.abs(angle % 90) + if remainder > 45 then + return 90 - remainder + else + return remainder + end +end + +-- Returns an integer that we need to subtract from width/height +-- due to camera rotation issues. +function cropAmount(width, height, angle) + local absAngle = angleRound(angle or 0) + if (absAngle > 0) then + local x = (5.61 - 0.095 * math.pow(absAngle, 2) + 9.06 * absAngle) + local factor = x / 640 + local longEdge = math.max(width, height) + local result = round(longEdge * factor) + return result + end + return 0 +end + +function fwe(key) + local e = env("CAMERA_CALIBRATION_" .. key) + if e then + return tonumber(e) + else + return nil + end +end + +function photo_grid() + local cam_rotation + if is_demo() then + cam_rotation = 0 + else + cam_rotation = fwe("total_rotation_angle") + end + local scale + if is_demo() then + scale = 1 + else + scale = fwe("coord_scale") + end + local z + if is_demo() then + z = 0 + else + z = fwe("camera_z") + end + local x_offset_mm + if is_demo() then + x_offset_mm = 0 + else + x_offset_mm = fwe("camera_offset_x") + end + local y_offset_mm + if is_demo() then + y_offset_mm = 0 + else + y_offset_mm = fwe("camera_offset_y") + end + local center_pixel_location_x + if is_demo() then + center_pixel_location_x = 320 + else + center_pixel_location_x = fwe("center_pixel_location_x") + end + local center_pixel_location_y + if is_demo() then + center_pixel_location_y = 240 + else + center_pixel_location_y = fwe("center_pixel_location_y") + end + local full_grid, x_spacing_mm, y_spacing_mm, x_grid_start_mm, y_grid_start_mm + local x_grid_size_mm, y_grid_size_mm, x_grid_points, y_grid_points + if cam_rotation and scale and z and x_offset_mm and + y_offset_mm and center_pixel_location_x and center_pixel_location_y then + local raw_img_size_x_mm = center_pixel_location_x * 2 * scale + local raw_img_size_y_mm = center_pixel_location_y * 2 * scale + local margin_mm = cropAmount(raw_img_size_x_mm, raw_img_size_y_mm, cam_rotation) + local cropped_img_size_x_mm = raw_img_size_x_mm - margin_mm - 5 + local cropped_img_size_y_mm = raw_img_size_y_mm - margin_mm - 5 + if math.abs(cam_rotation) < 45 then + x_spacing_mm = cropped_img_size_x_mm + y_spacing_mm = cropped_img_size_y_mm + else + x_spacing_mm = cropped_img_size_y_mm + y_spacing_mm = cropped_img_size_x_mm + end + x_spacing_mm = math.max(10, x_spacing_mm) + y_spacing_mm = math.max(10, y_spacing_mm) + x_grid_size_mm = garden_size().x - x_spacing_mm + y_grid_size_mm = garden_size().y - y_spacing_mm + x_grid_points = math.ceil(x_grid_size_mm / x_spacing_mm) + 1 + y_grid_points = math.ceil(y_grid_size_mm / y_spacing_mm) + 1 + x_grid_start_mm = (x_spacing_mm / 2) + y_grid_start_mm = (y_spacing_mm / 2) + + full_grid = grid{ + grid_points = { + x = x_grid_points, + y = y_grid_points, + z = 1, + }, + start = { + x = x_grid_start_mm, + y = y_grid_start_mm, + z = z, + }, + spacing = { + x = x_spacing_mm, + y = y_spacing_mm, + z = 0, + }, + offset = { + x = x_offset_mm, + y = y_offset_mm, + z = 0, + }, + ignore_bounds = true, + } + else + toast("You must first run camera calibration", "error") + end + + full_grid = full_grid or grid{ + grid_points = { x = 0, y = 0, z = 0 }, + spacing = { x = 0, y = 0, z = 0 }, + ignore_empty = true, + } + + local each = function(callback) + full_grid.each(function(cell) + callback({ x = cell.x, y = cell.y, z = cell.z, count = cell.count }) + end) + end + + return { + y_spacing_mm = y_spacing_mm, + y_offset_mm = y_offset_mm, + y_grid_start_mm = y_grid_start_mm, + y_grid_size_mm = y_grid_size_mm, + y_grid_points = y_grid_points, + x_spacing_mm = x_spacing_mm, + x_offset_mm = x_offset_mm, + x_grid_start_mm = x_grid_start_mm, + x_grid_size_mm = x_grid_size_mm, + x_grid_points = x_grid_points, + z = z, + total = full_grid.total, + each = each, + } +end + +function dismount_tool() + local tool_id = get_device("mounted_tool_id") + local start_time = os.time() * 1000 + + -- Checks + if not tool_id then + toast("No tool is mounted to the UTM", "error") + return + end + if not verify_tool() then + return + end + + -- Get all points + local points = api({ url = "/api/points/" }) + if not points then + toast("API error", "error") + return + end + + -- Pluck the tool slot point where the currently mounted tool belongs + local slot + local slot_dir + for key, point in pairs(points) do + if point.tool_id == tool_id then + slot = point + slot_dir = slot.pullout_direction + end + end + + -- Get tool name + local tool_name = get_tool{id = tool_id}.name + + -- Checks + if not slot then + toast("No slot found for the currently mounted tool (" .. tool_name .. ") - check the Tools panel", "error") + return + elseif slot_dir == 0 then + toast("Tool slot must have a direction", "error") + return + elseif slot.gantry_mounted then + toast("Tool slot cannot be gantry mounted", "error") + return + end + + -- Job progress tracking + function job(percent, status) + set_job_progress( + "Dismounting " .. tool_name, + { percent = percent, status = status, time = start_time } + ) + end + + -- Safe Z move to the front of the slot + job(20, "Retracting Z") + move{z = safe_z()} + + job(40, "Moving to front of slot") + if slot_dir == 1 then + move{x = slot.x + 100, y = slot.y} + elseif slot_dir == 2 then + move{x = slot.x - 100, y = slot.y} + elseif slot_dir == 3 then + move{x = slot.x, y = slot.y + 100} + elseif slot_dir == 4 then + move{x = slot.x, y = slot.y - 100} + end + + job(60, "Lowering Z") + move{z = slot.z} + + -- Put the tool in the slot + job(80, "Putting tool in slot") + move_absolute(slot.x, slot.y, slot.z, 50) + if is_demo() then + update_device({mounted_tool_id = 0}) + end + + -- Dismount tool + job(90, "Dismounting tool") + move{z = slot.z + 50} + + -- Check verification pin + if read_pin(63) == 0 and not is_demo() then + job(90, "Failed") + toast("Tool dismounting failed - there is still an electrical connection between UTM pins B and C.", "error") + return + else + job(100, "Complete") + if not is_demo() then + update_device({mounted_tool_id = 0}) + end + toast(tool_name .. " dismounted", "success") + end +end + +function dispense(ml, params) + params = params or {} + local tool_name = params.tool_name or "Watering Nozzle" + local pin_number = params.pin or 8 + + -- Get flow_rate + local tool = get_tool{name = tool_name} + if not tool then + toast('Tool "' .. tool_name .. '" not found', 'error') + return + end + local flow_rate = tool.flow_rate_ml_per_s + + -- Checks + if not flow_rate then + toast('You must have a tool named "' .. tool_name .. '" to use this sequence.', 'error') + return + elseif flow_rate == 0 then + toast("**FLOW RATE (mL/s)** must be greater than 0 for the " .. tool_name .. " tool.", "error") + return + elseif ml <= 0 then + toast("Liquid volume was 0mL. Skipping.", "warn") + return + elseif ml > 10000 then + toast("Liquid volume cannot be more than 10,000mL", "error") + return + end + + local seconds = math.floor(ml / flow_rate * 10) / 10 + local status = "Dispensing" + local job_message = status .. " " .. ml .. "mL" + local log_message = job_message .. " over " .. seconds .. " seconds" + + -- Action + send_message("info", log_message) + on(pin_number) + wait(seconds * 1000, { + job = job_message, + status = status, + }) + off(pin_number) +end + +function get_curve(curve_id) + local api_curve_data = api({ url = "/api/curves/" .. curve_id }) + if not api_curve_data then + toast("API error. Is your curve ID correct?", "error") + return + end + + function get_day_value(day) + local day = tonumber(day) + local day_string = tostring(day) + local value = api_curve_data.data[day_string] + if value ~= nil then + return value + end + + local data_days = {} + local i = 0 + for day_key, _ in pairs(api_curve_data.data) do + i = i + 1 + data_days[i] = tonumber(day_key) + end + table.sort(data_days) + + local greater_days = {} + local i = 0 + for _, k in pairs(data_days) do + if k > day then + i = i + 1 + greater_days[i] = k + end + end + table.sort(greater_days) + + local lesser_days = {} + local i = 0 + for _, k in pairs(data_days) do + if k < day then + i = i + 1 + lesser_days[i] = k + end + end + table.sort(lesser_days) + + local prev_day = lesser_days[#lesser_days] + local next_day = greater_days[1] + + if prev_day == nil then + local first_day = tostring(math.floor(data_days[1])) + return api_curve_data.data[first_day] + end + + if next_day == nil then + local last_day = tostring(math.floor(data_days[#data_days])) + return api_curve_data.data[last_day] + end + + local prev_value = api_curve_data.data[tostring(math.floor(prev_day))] + local next_value = api_curve_data.data[tostring(math.floor(next_day))] + + local exact_value = (prev_value * (next_day - day) + next_value * (day - prev_day)) + / (next_day - prev_day) + return tonumber(string.format("%.2f", exact_value)) + end + + local unit + if api_curve_data.type == "water" then + unit = "mL" + else + unit = "mm" + end + + local curve = { + name = api_curve_data.name, + type = api_curve_data.type, + unit = unit, + day = get_day_value, + } + + return curve +end + +function get_seed_tray_cell(tray, tray_cell) + local cell = string.upper(tray_cell) + local seeder_needle_offset = 17.5 + local cell_spacing = 12.5 + local cells = { + A1 = {label = "A1", x = 0, y = 0}, + A2 = {label = "A2", x = 0, y = 1}, + A3 = {label = "A3", x = 0, y = 2}, + A4 = {label = "A4", x = 0, y = 3}, + B1 = {label = "B1", x = -1, y = 0}, + B2 = {label = "B2", x = -1, y = 1}, + B3 = {label = "B3", x = -1, y = 2}, + B4 = {label = "B4", x = -1, y = 3}, + C1 = {label = "C1", x = -2, y = 0}, + C2 = {label = "C2", x = -2, y = 1}, + C3 = {label = "C3", x = -2, y = 2}, + C4 = {label = "C4", x = -2, y = 3}, + D1 = {label = "D1", x = -3, y = 0}, + D2 = {label = "D2", x = -3, y = 1}, + D3 = {label = "D3", x = -3, y = 2}, + D4 = {label = "D4", x = -3, y = 3} + } + + -- Checks + if tray.pointer_type ~= "ToolSlot" then + toast("Seed Tray variable must be a seed tray in a slot", "error") + return + elseif not cells[cell] then + toast("Seed Tray Cell must be one of **A1** through **D4**", "error") + return + end + + -- Flip X offsets depending on pullout direction + local flip = 1 + if tray.pullout_direction == 1 then + flip = 1 + elseif tray.pullout_direction == 2 then + flip = -1 + else + send_message("error", "Seed Tray **SLOT DIRECTION** must be \`Positive X\` or \`Negative X\`") + return + end + + -- A1 coordinates + local A1 = { + x = tray.x - seeder_needle_offset + (1.5 * cell_spacing * flip), + y = tray.y - (1.5 * cell_spacing * flip), + z = tray.z + } + + -- Cell offset from A1 + local offset = { + x = cell_spacing * cells[cell].x * flip, + y = cell_spacing * cells[cell].y * flip + } + + -- Return cell coordinates + return { + x = A1.x + offset.x, + y = A1.y + offset.y, + z = A1.z + } +end + +function mount_tool(input) + local slot + if type(input) == "string" then + local prelim_tool + local tool_name = input + prelim_tool = get_tool{name = tool_name} + if not prelim_tool then + toast("'" .. tool_name .. "' tool not found", "error") + return + end + + local points = api({ url = "/api/points/" }) + if not points then + toast("API error", "error") + return + end + for key, point in pairs(points) do + if point.tool_id == prelim_tool.id then + slot = point + end + end + else + slot = input + end + + if not slot then + toast("Tool slot not found", "error") + return + end + + local slot_dir = slot.pullout_direction + local start_time = os.time() * 1000 + + -- Checks + if read_pin(63) == 0 then + toast("A tool is already mounted to the UTM - there is an electrical connection between UTM pins B and C.", "error") + return + elseif get_device("mounted_tool_id") then + toast("There is already a tool mounted to the UTM - check the **MOUNTED TOOL** dropdown in the Tools panel.", "error") + return + elseif slot.pointer_type ~= "ToolSlot" then + toast("Provided location must be a tool in a slot", "error") + return + elseif slot_dir == 0 then + toast("Tool slot must have a direction", "error") + return + elseif slot.gantry_mounted then + toast("Tool slot cannot be gantry mounted", "error") + return + end + + local tool = get_tool{id = slot.tool_id} + if not tool then + toast("Tool slot must have a tool", "error") + return + end + + -- Job progress tracking + function job(percent, status) + set_job_progress( + "Mounting " .. tool.name, + { percent = percent, status = status, time = start_time } + ) + end + + -- Safe Z move to above the tool + job(20, "Retracting Z") + move{z=safe_z()} + job(40, "Moving above tool") + move{x=slot.x, y=slot.y} + + -- Mount the tool + job(60, "Mounting tool") + move{z=slot.z} + if is_demo() then + update_device({mounted_tool_id = slot.tool_id}) + end + + -- Pull the tool out of the slot at 50% speed + job(80, "Pulling tool out") + if slot_dir == 1 then + move_absolute(slot.x + 100, slot.y, slot.z, 50) + elseif slot_dir == 2 then + move_absolute(slot.x - 100, slot.y, slot.z, 50) + elseif slot_dir == 3 then + move_absolute(slot.x, slot.y + 100, slot.z, 50) + elseif slot_dir == 4 then + move_absolute(slot.x, slot.y - 100, slot.z, 50) + end + + -- Check verification pin + if read_pin(63) == 1 and not is_demo() then + job(80, "Failed") + toast("Tool mounting failed - no electrical connection between UTM pins B and C.", "error") + return + else + job(100, "Complete") + if not is_demo() then + update_device({mounted_tool_id = slot.tool_id}) + end + toast(tool.name .. " mounted", "success") + end +end + +function axis_overwrite(axis, num) + return { + kind = "axis_overwrite", + args = { + axis = axis, + axis_operand = {kind = "numeric", args = {number = num}} + } + } +end + +function speed_overwrite(axis, num) + return { + kind = "speed_overwrite", + args = { + axis = axis, + speed_setting = {kind = "numeric", args = {number = num}} + } + } +end + +function axis_order(params) + local grouping = params.grouping or "xyz" + local route = params.route or "in_order" + return { kind = "axis_order", args = { grouping = grouping, route = route } } +end + +function move(input) + cs_eval({ + kind = "rpc_request", + args = {label = "move_cmd_lua", priority = 500}, + body = { + { + kind = "move", + args = {}, + body = { + input.x and axis_overwrite("x", input.x), + input.y and axis_overwrite("y", input.y), + input.z and axis_overwrite("z", input.z), + input.speed and speed_overwrite("x", input.speed), + input.speed and speed_overwrite("y", input.speed), + input.speed and speed_overwrite("z", input.speed), + (input.grouping or input.route) and axis_order(input), + input.safe_z and {kind = "safe_z", args = {}} + } + } + } + }) +end + +function rpc(rpc_node) + local label = "" .. math.random() .. math.random(); + return cs_eval({ + kind = "rpc_request", + args = {label = label}, + body = {rpc_node} + }) +end + +function sequence(sequence_id, params) + if not params then + return rpc({ + kind = "execute", + args = {sequence_id = sequence_id} + }) + end + local body = {} + local i = 0 + for key, data_value in pairs(params) do + i = i + 1 + body[i] = { + kind = "parameter_application", + args = {label = key, data_value = data_value} + } + end + return rpc({ + kind = "execute", + args = {sequence_id = sequence_id}, + body = body + }) +end + +function verify_tool() + local mounted_tool_id = get_device("mounted_tool_id") + + if read_pin(63) == 1 then + toast("No tool detected on the UTM - there is no electrical connection between UTM pins B and C.", "error") + return false + end + + if not mounted_tool_id then + toast("A tool is mounted but FarmBot does not know which one - check the **MOUNTED TOOL** dropdown in the Tools panel.", "error") + return false + end + + local mounted_tool_name = get_tool{id = mounted_tool_id}.name + send_message("success", "The " .. mounted_tool_name .. " is mounted on the UTM") + return true +end + +function wait(milliseconds, params) + params = params or {} + local seconds = milliseconds / 1000 + local job = params.job or "Waiting " .. seconds .. "s" + local status = params.status or "Waiting" + local start_time = os.time() * 1000 + + if milliseconds < 1000 then + wait_ms(milliseconds) + else + for i = 1, seconds do + set_job_progress(job, { + percent = math.floor((i - 1) / seconds * 100), + status = status, + time = start_time, + }) + wait_ms(1000) + end + wait_ms(milliseconds % 1000) + set_job_progress(job, { + percent = 100, + status = "Complete", + time = start_time, + }) + end +end + +function water(plant, params) + local plant_name_xy = plant.name .. " at (" .. plant.x .. ", " .. plant.y .. ")" + local job_name = "Watering " .. plant.name + + if not plant.age and not plant.planted_at then + toast(plant_name_xy .. " has not been planted yet. Skipping.", "warn") + return + end + + if plant.age then + plant_age = plant.age + else + plant_age = math.ceil((os.time() - to_unix(plant.planted_at)) / 86400) + end + + -- Get water curve and water amount in mL + local water_curve, water_ml + if plant.water_curve_id then + water_curve = get_curve(plant.water_curve_id) + water_ml = water_curve.day(plant_age) + else + toast(plant_name_xy .. " has no assigned water curve. Skipping.", "warn") + return + end + + -- Move to the plant + set_job(job_name, { status = "Moving" }) + move{ x = plant.x, y = plant.y, z = safe_z() } + + -- Water the plant + set_job(job_name, { status = "Watering", percent = 50 }) + send_message("info", "Watering " .. plant_age .. " day old " .. plant.name .. " " .. water_ml .. "mL") + dispense(water_ml, params) + complete_job(job_name) +end +`; + +const ALIASES = ` +function on(pin) + write_pin(pin, "digital", 1) +end + +function off(pin) + write_pin(pin, "digital", 0) +end + +function toast(message, type) + local type = type or "info" + send_message(type, message, "toast") +end + +function debug(message) + send_message("debug", message) +end + +function iso8601(date) + return string.format("%04d-%02d-%02dT%02d:%02d:%02dZ", + date.year, date.month, date.day, + date.hour, date.min, date.sec) +end + +function utc(part) + local now = os.date("*t") + local map = { + year = now.year, + month = now.month, + day = now.day, + hour = now.hour, + minute = now.min, + second = now.sec, + } + return map[part] or iso8601(now) +end + +function current_year() + return utc("year") +end + +function current_month() + return utc("month") +end + +function current_day() + return utc("day") +end + +function current_hour() + return utc("hour") +end + +function current_minute() + return utc("minute") +end + +function current_second() + return utc("second") +end + +function get_tool(params) + local tool_id = params.id or 0 + local tool_name = params.name or "" + local tools = api({ url = "/api/tools" }) + tools = tools or {} + for _, tool in ipairs(tools) do + if tool.id == tool_id or tool.name == tool_name then + return tool + end + end + return nil +end + +function inspect(input) + return json.encode(input) +end + +function complete_job(name) + set_job(name, { status = "Complete", percent = 100 }) +end + +function get_job_progress(name) + get_job(name) +end + +function is_demo() + return true +end +`; + +export const LUA_HELPERS = [ + ALIASES, + FROM_FBOS, +].join("\n"); diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts new file mode 100644 index 0000000000..53ce5827c2 --- /dev/null +++ b/frontend/demo/lua_runner/run.ts @@ -0,0 +1,609 @@ +import { lua, lauxlib, lualib, to_luastring } from "fengari-web"; +import { + getDeviceAccountSettings, + selectAllCurves, + selectAllGenericPointers, + selectAllPlantPointers, + selectAllPoints, selectAllTools, selectAllToolSlotPointers, + selectAllWeedPointers, +} from "../../resources/selectors"; +import { + ParameterApplication, PercentageProgress, RpcRequest, TaggedPoint, uuid, Xyz, +} from "farmbot"; +import { store } from "../../redux/store"; +import { sortGroupBy } from "../../point_groups/point_group_sort"; +import { LUA_HELPERS } from "./lua"; +import { + clean, createRecursiveNotImplemented, csToLua, filterPoint, jsToLua, luaToJs, +} from "./util"; +import { Action, XyzNumber } from "./interfaces"; +import { + DeviceAccountSettings, Point, PointGroupSortType, +} from "farmbot/dist/resources/api_resources"; +import { + getFirmwareSettings, getGardenSize, getSafeZ, getSoilHeight, + getGroupPoints, getJob, +} from "./stubs"; +import { error } from "../../toast/toast"; +import { collectDemoSequenceActions } from "./index"; +import { last } from "lodash"; + +export const runLua = + (depth: number, luaCode: string, variables: ParameterApplication[]): Action[] => { + const actions: Action[] = []; + const L = lauxlib.luaL_newstate(); // stack: [] + + lua.lua_newtable(L); // stack: [env] + const envIndex = lua.lua_gettop(L); + + lauxlib.luaL_requiref(L, to_luastring("_G"), lualib.luaopen_base, 1); + const gIndex = lua.lua_gettop(L); + + lua.lua_getfield(L, gIndex, to_luastring("type")); + lua.lua_setfield(L, envIndex, to_luastring("type")); + + lua.lua_getfield(L, gIndex, to_luastring("tostring")); + lua.lua_setfield(L, envIndex, to_luastring("tostring")); + + lua.lua_getfield(L, gIndex, to_luastring("tonumber")); + lua.lua_setfield(L, envIndex, to_luastring("tonumber")); + + lua.lua_getfield(L, gIndex, to_luastring("pairs")); + lua.lua_setfield(L, envIndex, to_luastring("pairs")); + + lua.lua_getfield(L, gIndex, to_luastring("ipairs")); + lua.lua_setfield(L, envIndex, to_luastring("ipairs")); + + lua.lua_pop(L, 1); // stack: [env] + + lauxlib.luaL_requiref(L, to_luastring("math"), lualib.luaopen_math, 1); + lua.lua_setfield(L, envIndex, to_luastring("math")); + + lauxlib.luaL_requiref(L, to_luastring("table"), lualib.luaopen_table, 1); + lua.lua_setfield(L, envIndex, to_luastring("table")); + + lauxlib.luaL_requiref(L, to_luastring("string"), lualib.luaopen_string, 1); + lua.lua_setfield(L, envIndex, to_luastring("string")); + + lua.lua_pushjsfunction(L, () => { + let output = ""; + const n = lua.lua_gettop(L); + for (let i = 1; i <= n; i++) { + if (i > 1) { output += "\t"; } + if (lua.lua_isstring(L, i)) { + output += luaToJs(L, i); + } else { + output += JSON.stringify(luaToJs(L, i)); + } + } + actions.push({ type: "print", args: [output] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("print")); + + lua.lua_pushjsfunction(L, () => { + const input = luaToJs(L, 1); + const output = JSON.stringify(input); + jsToLua(L, output); + return 1; + }); + lua.lua_pushjsfunction(L, () => { + const input = luaToJs(L, 1) as string; + try { + const output = JSON.parse(input); + jsToLua(L, output); + } catch (e) { + jsToLua(L, undefined); + } + return 1; + }); + lua.lua_newtable(L); + lua.lua_pushvalue(L, -3); + lua.lua_setfield(L, -2, to_luastring("encode")); + lua.lua_pushvalue(L, -2); + lua.lua_setfield(L, -2, to_luastring("decode")); + lua.lua_setfield(L, envIndex, to_luastring("json")); + lua.lua_pop(L, 2); + + lua.lua_pushjsfunction(L, () => { + const variableName = luaToJs(L, 1) as string; + const n = variables + .filter(variable => variable.args.label === variableName) + .map(variable => variable.args.data_value)[0]; + switch (n?.kind) { + case "numeric": + jsToLua(L, n.args.number); + break; + case "text": + jsToLua(L, n.args.string); + break; + case "coordinate": + jsToLua(L, n.args); + break; + case "point": + const point = selectAllPoints(store.getState().resources.index) + .find(p => p.body.id === n.args.pointer_id)?.body; + jsToLua(L, clean(point)); + break; + case "tool": + const slot = selectAllToolSlotPointers(store.getState().resources.index) + .find(ts => ts.body.tool_id === n.args.tool_id)?.body; + jsToLua(L, clean(slot)); + break; + default: + actions.push({ + type: "send_message", + args: [ + "error", + `Variable "${variableName}" of type ${n?.kind} not implemented.`, + ], + }); + lua.lua_pushnil(L); + break; + } + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("variable")); + + // stack: [env] + lauxlib.luaL_requiref(L, to_luastring("os"), lualib.luaopen_os, 1); + // stack: [env, os] + const osIndex = lua.lua_gettop(L); + lua.lua_newtable(L); + const envOsIndex = lua.lua_gettop(L); + lua.lua_getfield(L, osIndex, to_luastring("time")); + const rawTime = lua.lua_toproxy(L, -1); + lua.lua_pop(L, 1); + lua.lua_pushjsfunction(L, () => { + rawTime(L); + lua.lua_call(L, 0, 1); + const intTime = luaToJs(L, -1) as number; + lua.lua_pop(L, 1); + jsToLua(L, intTime + 0.0); + return 1; + }); + lua.lua_setfield(L, envOsIndex, to_luastring("time")); + lua.lua_getfield(L, osIndex, to_luastring("date")); + lua.lua_setfield(L, envOsIndex, to_luastring("date")); + lua.lua_setfield(L, envIndex, to_luastring("os")); + lua.lua_pop(L, 1); // stack: [env] + + lua.lua_pushjsfunction(L, () => { + lua.lua_getfield(L, 1, to_luastring("method")); + const rawMethod = lua.lua_isnil(L, -1) + ? "GET" + : luaToJs(L, -1) as string; + const method = rawMethod.toUpperCase(); + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 1, to_luastring("url")); + const rawUrl = luaToJs(L, -1) as string; + const url = rawUrl.replace(/\/$/, ""); + lua.lua_pop(L, 1); + + if (url == "/api/points") { + const points = selectAllPoints(store.getState().resources.index); + if (method == "GET") { + const results = sortGroupBy("yx_alternating", points) + .map(p => p.body).map(clean); + jsToLua(L, results); + return 1; + } + if (method == "POST") { + lua.lua_getfield(L, 1, to_luastring("body")); + const body = luaToJs(L, -1) as Object; + lua.lua_pop(L, 1); + const point = JSON.stringify(body); + actions.push({ type: "create_point", args: [point] }); + jsToLua(L, true); + return 1; + } + } else if (method == "GET" && url == "/api/tools") { + const results = selectAllTools(store.getState().resources.index) + .map(p => p.body).map(clean); + jsToLua(L, results); + return 1; + } else if (method == "GET" && url.startsWith("/api/curves")) { + const curveId = parseInt("" + last(url.split("/"))); + const curve = selectAllCurves(store.getState().resources.index) + .map(curve => curve.body) + .filter(curve => curve.id == curveId)[0]; + jsToLua(L, clean(curve)); + return 1; + } else { + actions.push({ + type: "send_message", + args: [ + "error", + `API call ${method} ${url} not implemented.`, + ], + }); + jsToLua(L, false); + return 1; + } + }); + lua.lua_setfield(L, envIndex, to_luastring("api")); + + lua.lua_pushjsfunction(L, () => { + const params = luaToJs(L, 1) as Partial>; + const plants = selectAllPlantPointers(store.getState().resources.index) + .map(plant => plant.body) + .filter(filterPoint(params, "planted")) + .map(clean); + jsToLua(L, plants); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_plants")); + + lua.lua_pushjsfunction(L, () => { + const params = luaToJs(L, 1) as Partial>; + const weeds = selectAllWeedPointers(store.getState().resources.index) + .map(weed => weed.body) + .filter(filterPoint(params, "active")) + .map(clean); + jsToLua(L, weeds); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_weeds")); + + lua.lua_pushjsfunction(L, () => { + const params = luaToJs(L, 1) as Partial>; + const points = selectAllGenericPointers(store.getState().resources.index) + .map(point => point.body) + .filter(filterPoint(params, undefined)) + .map(clean); + jsToLua(L, points); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_generic_points")); + + lua.lua_pushjsfunction(L, () => { + const groupId = luaToJs(L, 1) as number; + const points = getGroupPoints(store.getState().resources.index, groupId) + .map(point => point.body).map(clean); + jsToLua(L, points); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_group")); + + lua.lua_pushjsfunction(L, () => { + const groupId = luaToJs(L, 1) as number; + const points = getGroupPoints(store.getState().resources.index, groupId) + .map(point => point.body.id).map(clean); + jsToLua(L, points); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("group")); + + lua.lua_pushjsfunction(L, () => { + const points = luaToJs(L, 1) as Point[]; + const sortMethod = luaToJs(L, 2) as PointGroupSortType; + const taggedPoints = points.map(point => ({ + body: point, + uuid: uuid(), + })) as TaggedPoint[]; + const results = sortGroupBy(sortMethod, taggedPoints) + .map(p => p.body).map(clean); + jsToLua(L, results); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("sort")); + + lua.lua_pushjsfunction(L, () => { + const datetimeString = luaToJs(L, 1) as string; + const unix = new Date(datetimeString).getTime() / 1000; + jsToLua(L, unix); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("to_unix")); + + lua.lua_pushjsfunction(L, () => { + const cmd = (luaToJs(L, 1) as RpcRequest).body?.[0]; + if (!cmd) { return 0; } + if (cmd.kind == "execute") { + const ri = store.getState().resources.index; + const sequenceId = cmd.args.sequence_id; + const seqVariables = cmd.body; + const seqActions = collectDemoSequenceActions( + depth + 1, ri, sequenceId, seqVariables); + actions.push(...seqActions); + } else { + const luaActions = runLua(depth, csToLua(cmd), variables); + actions.push(...luaActions); + } + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("cs_eval")); + + lua.lua_pushjsfunction(L, () => { + const n = lua.lua_gettop(L); + const args = []; + for (let i = 1; i <= n; i++) { + args.push(luaToJs(L, i) as string); + } + if (Array.isArray(args[2])) { + args[2] = args[2].join(","); + } + actions.push({ type: "send_message", args: args }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("send_message")); + + lua.lua_pushjsfunction(L, () => { + const jobName = luaToJs(L, 1) as string; + + lua.lua_getfield(L, 2, to_luastring("percent")); + const percent = luaToJs(L, -1) as number; + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 2, to_luastring("status")); + const status = luaToJs(L, -1) as string; + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 2, to_luastring("time")); + const time = luaToJs(L, -1) as number; + lua.lua_pop(L, 1); + + actions.push({ + type: "set_job_progress", + args: [jobName, percent, status, time], + }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("set_job_progress")); + + lua.lua_pushjsfunction(L, () => { + const jobName = luaToJs(L, 1) as string; + const job = getJob(jobName); + jsToLua(L, job); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_job")); + + lua.lua_pushjsfunction(L, () => { + const jobName = luaToJs(L, 1) as string; + const params = luaToJs(L, 2) as Partial; + const time = Date.now(); + const prev = getJob(jobName); + const existing = prev?.status.toLowerCase() != "complete" ? prev : {}; + const job = { time, ...existing, ...params }; + actions.push({ + type: "set_job_progress", + args: [jobName, job.percent, job.status, job.time], + }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("set_job")); + + lua.lua_pushjsfunction(L, () => { + const args = []; + const n = lua.lua_gettop(L); + if (n == 1) { + const params = luaToJs(L, 1) as XyzNumber; + ["x", "y", "z"].map((axis: Xyz) => args.push(params[axis])); + } else { + for (let i = 1; i <= n; i++) { + args.push(luaToJs(L, i) as number); + } + } + actions.push({ type: "move_absolute", args: args }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("move_absolute")); + + lua.lua_pushjsfunction(L, () => { + const n = lua.lua_gettop(L); + const args = []; + for (let i = 1; i <= n; i++) { + args.push(luaToJs(L, i) as number); + } + actions.push({ type: "move_relative", args: args }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("move_relative")); + + lua.lua_pushjsfunction(L, () => { + const axis = luaToJs(L, -1) as string; + actions.push({ type: "find_home", args: [axis] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("find_home")); + + lua.lua_pushjsfunction(L, () => { + const axis = luaToJs(L, -1) as string; + actions.push({ type: "go_to_home", args: [axis] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("go_to_home")); + + lua.lua_pushjsfunction(L, () => { + const axis = luaToJs(L, -1) as string; + const firmwareSettings = getFirmwareSettings(); + const sign = { + x: 1, + y: 1, + z: firmwareSettings.movement_home_up_z ? -1 : 1, + }; + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? sign.x * -9999 : 0, + axis == "y" ? sign.y * -9999 : 0, + axis == "z" ? sign.z * -9999 : 0, + ], + }); + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? sign.x * 9999 : 0, + axis == "y" ? sign.y * 9999 : 0, + axis == "z" ? sign.z * 9999 : 0, + ], + }); + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? sign.x * -9999 : 0, + axis == "y" ? sign.y * -9999 : 0, + axis == "z" ? sign.z * -9999 : 0, + ], + }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("find_axis_length")); + + lua.lua_pushjsfunction(L, () => { + const ms = luaToJs(L, 1) as number; + actions.push({ type: "wait_ms", args: [ms] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("wait_ms")); + + lua.lua_pushjsfunction(L, () => { + const key = luaToJs(L, 1) as keyof DeviceAccountSettings; + const device = getDeviceAccountSettings(store.getState().resources.index); + const value = device.body[key]; + jsToLua(L, value || false); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("get_device")); + + lua.lua_pushjsfunction(L, () => { + const params = luaToJs(L, 1) as Object; + const [key, value] = Object.entries(params)[0]; + actions.push({ type: "update_device", args: [key, value] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("update_device")); + + lua.lua_pushjsfunction(L, () => { + jsToLua(L, getSafeZ()); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("safe_z")); + + lua.lua_pushjsfunction(L, () => { + jsToLua(L, ""); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("env")); + + lua.lua_pushjsfunction(L, () => { + const x = luaToJs(L, 1) as number; + const y = luaToJs(L, 2) as number; + jsToLua(L, getSoilHeight(x, y)); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("soil_height")); + + lua.lua_pushjsfunction(L, () => { + const arg = luaToJs(L, 1) as string; + actions.push({ type: "_move", args: [arg] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("_move")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "take_photo", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("take_photo")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "calibrate_camera", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("calibrate_camera")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "detect_weeds", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("detect_weeds")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "measure_soil_height", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("measure_soil_height")); + + lua.lua_pushjsfunction(L, () => { + const pin = luaToJs(L, 1) as number; + if (pin == 63) { + const toolMounted = + !!getDeviceAccountSettings(store.getState().resources.index) + .body.mounted_tool_id; + jsToLua(L, toolMounted ? 0 : 1); + return 1; + } + jsToLua(L, 0); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("read_pin")); + + lua.lua_pushjsfunction(L, () => { + const pin = luaToJs(L, 1) as number; + const mode = luaToJs(L, 2) as number; + const value = luaToJs(L, 3) as number; + actions.push({ type: "write_pin", args: [pin, mode, value] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("write_pin")); + + lua.lua_pushjsfunction(L, () => { + const pin = luaToJs(L, 1) as number; + actions.push({ type: "toggle_pin", args: [pin] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("toggle_pin")); + + lua.lua_pushjsfunction(L, () => { + const lengths = getGardenSize(); + jsToLua(L, lengths); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("garden_size")); + + lauxlib.luaL_loadstring(L, to_luastring(LUA_HELPERS)); + lua.lua_pushvalue(L, -2); + lua.lua_setupvalue(L, -2, 1); + lua.lua_pcall(L, 0, 0, 0); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "emergency_lock", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("emergency_lock")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "emergency_unlock", args: [] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("emergency_unlock")); + + lua.lua_newtable(L); + lua.lua_pushjsfunction(L, () => { + const key = luaToJs(L, 2) as string; + return createRecursiveNotImplemented(L, actions, [key]); + }); + lua.lua_setfield(L, -2, to_luastring("__index")); + lua.lua_setmetatable(L, -2); + + const statusLoad = lauxlib.luaL_loadstring(L, to_luastring(luaCode)); + if (statusLoad !== lua.LUA_OK) { + const errorMsg = `Lua load error: ${luaToJs(L, -1)}`; + error(errorMsg); + return []; + } + + lua.lua_pushvalue(L, -2); + lua.lua_setupvalue(L, -2, 1); + + const statusCall = lua.lua_pcall(L, 0, lua.LUA_MULTRET, 0); + if (statusCall !== lua.LUA_OK) { + const errorMsg = `Lua call error: ${luaToJs(L, -1)}`; + error(errorMsg); + return []; + } + return actions; + }; diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts new file mode 100644 index 0000000000..23f2ade38d --- /dev/null +++ b/frontend/demo/lua_runner/stubs.ts @@ -0,0 +1,97 @@ +import { store } from "../../redux/store"; +import { + ALLOWED_GROUPING, + ALLOWED_ROUTE, + AxisOrder, + JobProgress, + SafeZ, + TaggedFbosConfig, TaggedFirmwareConfig, TaggedWebAppConfig, +} from "farmbot"; +import { calculateAxialLengths } from "../../controls/move/direction_axes_props"; +import { + getFbosConfig, getFirmwareConfig, getWebAppConfig, +} from "../../resources/getters"; +import { XyzNumber } from "./interfaces"; +import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware"; +import { WebAppConfig } from "farmbot/dist/resources/configs/web_app"; +import { FbosConfig } from "farmbot/dist/resources/configs/fbos"; +import { + selectAllPointGroups, selectAllPoints, +} from "../../resources/selectors_by_kind"; +import { pointsSelectedByGroup } from "../../point_groups/criteria/apply"; +import { sortGroupBy } from "../../point_groups/point_group_sort"; +import { ResourceIndex } from "../../resources/interfaces"; +import { getZFunc, TriangleData } from "../../three_d_garden/triangle_functions"; + +export const getFirmwareSettings = (): FirmwareConfig => { + const fwConfig = getFirmwareConfig(store.getState().resources.index); + const firmwareSettings = (fwConfig as TaggedFirmwareConfig).body; + return firmwareSettings; +}; + +export const getWebAppSettings = (): WebAppConfig => { + const webAppConfig = getWebAppConfig(store.getState().resources.index); + const webAppSettings = (webAppConfig as TaggedWebAppConfig).body; + return webAppSettings; +}; + +export const getFbosSettings = (): FbosConfig => { + const fbosConfig = getFbosConfig(store.getState().resources.index); + const fbosSettings = (fbosConfig as TaggedFbosConfig).body; + return fbosSettings; +}; + +export const getGardenSize = (): XyzNumber => { + const firmwareSettings = getFirmwareSettings(); + const lengths = calculateAxialLengths({ firmwareSettings }); + const webAppSettings = getWebAppSettings(); + return { + x: lengths.x || webAppSettings.map_size_x, + y: lengths.y || webAppSettings.map_size_y, + z: lengths.z || 500, + }; +}; + +export const getSafeZ = (): number => { + const fbosSettings = getFbosSettings(); + return fbosSettings.safe_height || 0; +}; + +export const getSoilHeight = (x: number, y: number): number => { + const triangles = JSON.parse( + sessionStorage.getItem("triangles") || "[]") as TriangleData[]; + const getZ = getZFunc(triangles, -500); + return getZ(x, y); +}; + +export const getGroupPoints = (resources: ResourceIndex, groupId: number) => { + const allPoints = selectAllPoints(resources); + const group = selectAllPointGroups(resources) + .filter(group => group.body.id === groupId)[0]; + const groupPoints = pointsSelectedByGroup(group, allPoints); + return sortGroupBy(group.body.sort_type, groupPoints); +}; + +export const getDefaultAxisOrder = (): (SafeZ | AxisOrder)[] => { + const fbosConfig = getFbosConfig(store.getState().resources.index); + const defaultAxisOrder = fbosConfig?.body.default_axis_order; + switch (defaultAxisOrder) { + case "safe_z": + return [{ kind: "safe_z", args: {} }]; + case undefined: + return []; + default: + const [grouping, route] = + defaultAxisOrder.split(";") as [ALLOWED_GROUPING, ALLOWED_ROUTE]; + return [{ kind: "axis_order", args: { grouping, route } }]; + } +}; + +export const getJob = (jobName: string): JobProgress | undefined => { + const { jobs } = store.getState().bot.hardware; + return Object.entries(jobs) + .map(([key, value]) => + key === jobName + ? value + : undefined)[0]; +}; diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts new file mode 100644 index 0000000000..e5127e0951 --- /dev/null +++ b/frontend/demo/lua_runner/util.ts @@ -0,0 +1,221 @@ +import { lua, to_jsstring, to_luastring } from "fengari-web"; +import { Action } from "./interfaces"; +import { RpcRequestBodyItem } from "farmbot"; +import { maybeFindPeripheralById } from "../../resources/selectors_by_id"; +import { store } from "../../redux/store"; +import { + GenericPointer, PlantPointer, Point, ToolSlotPointer, WeedPointer, +} from "farmbot/dist/resources/api_resources"; +import moment from "moment"; + +export const createRecursiveNotImplemented = ( + L: unknown, + actions: Action[], + path: string[], +) => { + lua.lua_newtable(L); + lua.lua_newtable(L); + lua.lua_pushjsfunction(L, () => { + const key = luaToJs(L, 2) as string; + return createRecursiveNotImplemented(L, actions, [...path, key]); + }); + lua.lua_setfield(L, -2, to_luastring("__index")); + + lua.lua_pushjsfunction(L, () => { + const fullPath = path.join("."); + actions.push({ + type: "send_message", + args: [ + "error", + `Lua function "${fullPath}" is not implemented.`, + ], + }); + jsToLua(L, false); + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("__call")); + + lua.lua_setmetatable(L, -2); + return 1; +}; + +export const luaToJs = (L: unknown, idx: number): unknown => { + const type = lua.lua_type(L, idx); + switch (type) { + case lua.LUA_TNIL: + return undefined; + case lua.LUA_TBOOLEAN: + return lua.lua_toboolean(L, idx); + case lua.LUA_TNUMBER: + return lua.lua_tonumber(L, idx); + case lua.LUA_TSTRING: + return to_jsstring(lua.lua_tostring(L, idx)); + case lua.LUA_TTABLE: + return luaTableToJs(L, idx); + default: + return `<${to_jsstring(lua.lua_typename(L, type))}>`; + } +}; + +const luaTableToJs = (L: unknown, idx: number): unknown => { + const absIndex = lua.lua_absindex(L, idx); + const keyVals: [string | number, unknown][] = []; + + lua.lua_pushnil(L); + while (lua.lua_next(L, absIndex)) { + const key = luaToJs(L, -2) as (string | number); + const val = luaToJs(L, -1); + keyVals.push([key, val]); + lua.lua_pop(L, 1); + } + const isArrayLike = + keyVals.every(([k]) => typeof k === "number"); + if (isArrayLike) { + return keyVals.map(([, v]) => v).filter(v => v !== undefined); + } else { + const result: Record = {}; + for (const [key, value] of keyVals) { + result["" + key] = value; + } + return result; + } +}; + +export const jsToLua = (L: unknown, value: unknown): void => { + if (value === undefined) { + lua.lua_pushnil(L); + } else if (typeof value === "boolean") { + lua.lua_pushboolean(L, value); + } else if (typeof value === "number") { + lua.lua_pushnumber(L, value); + } else if (typeof value === "string") { + lua.lua_pushstring(L, to_luastring(value)); + } else if (Array.isArray(value)) { + lua.lua_newtable(L); + for (let i = 0; i < value.length; i++) { + jsToLua(L, value[i]); + lua.lua_rawseti(L, -2, i + 1); + } + } else if (typeof value === "object") { + lua.lua_newtable(L); + for (const key in value) { + jsToLua(L, (value as Record)[key]); + lua.lua_setfield(L, -2, to_luastring(key)); + } + } else { + jsToLua(L, `<${typeof value}>`); + } +}; + +// eslint-disable-next-line complexity +export const csToLua = (command: RpcRequestBodyItem): string => { + const { kind, args, body } = command; + switch (kind) { + case "emergency_lock": + return "emergency_lock()"; + case "emergency_unlock": + return "emergency_unlock()"; + case "find_home": + return `find_home("${args.axis}")`; + case "home": + return `go_to_home("${args.axis}")`; + case "wait": + return `wait(${args.milliseconds})`; + case "send_message": + return `send_message("${args.message_type}", "${args.message}")`; + case "take_photo": + return "take_photo()"; + case "execute_script": + if (args.label == "plant-detection") { + return "detect_weeds()"; + } + if (args.label == "Measure Soil Height") { + return "measure_soil_height()"; + } + return ""; + case "move_relative": + return `move_relative(${args.x}, ${args.y}, ${args.z})`; + case "move_absolute": + const lKind = args.location.kind; + if (lKind == "coordinate") { + const cArgs = args.location.args; + return `move_absolute(${cArgs.x}, ${cArgs.y}, ${cArgs.z})`; + } + return `toast("move_absolute ${lKind} is not implemented", "error")`; + case "move": + const jsonString = JSON.stringify(JSON.stringify(body || [])); + return `_move(${jsonString})`; + case "write_pin": + let pin = undefined; + if (typeof args.pin_number == "object") { + const namedPin = maybeFindPeripheralById( + store.getState().resources.index, + args.pin_number.args.pin_id); + if (!namedPin) { return ""; } + pin = namedPin.body.pin; + } else { + pin = args.pin_number; + } + const mode = args.pin_mode ? "analog" : "digital"; + return `write_pin(${pin}, "${mode}", ${args.pin_value})`; + case "toggle_pin": + return `toggle_pin(${args.pin_number})`; + case "lua": + return args.lua; + default: + return `toast("celeryscript ${kind} is not implemented", "error")`; + } +}; + +export const clean = (data: Object | undefined): Object | undefined => + data + ? Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, value ?? undefined]), + ) + : undefined; + +type AllPoint = Omit + & Omit + & Omit + & Omit; + +export const filterPoint = ( + params: Partial>, + stage: string | undefined, + // eslint-disable-next-line complexity +) => (p: Point) => { + const point = p as unknown as AllPoint; + const plantStage = (params.plant_stage || stage) as string | undefined; + const openfarmSlug = params.openfarm_slug as string | undefined; + const minRadius = params.min_radius as number | undefined; + const maxRadius = params.max_radius as number | undefined; + const minAge = params.min_age as number | undefined; + const maxAge = params.max_age as number | undefined; + const color = params.color as string | undefined; + const atSoilLevel = params.at_soil_level as string | undefined; + const age = () => { + if (!point.planted_at) { return 0; } + return Math.ceil(moment().diff(moment(point.planted_at), "days")); + }; + + const stageFilter = plantStage == undefined || point.plant_stage == plantStage; + const slugFilter = openfarmSlug == + undefined || point.openfarm_slug == openfarmSlug; + const minRadiusFilter = minRadius == undefined || point.radius >= minRadius; + const maxRadiusFilter = maxRadius == undefined || point.radius <= maxRadius; + const minAgeFilter = minAge == undefined || age() >= minAge; + const maxAgeFilter = maxAge == undefined || age() <= maxAge; + const colorFilter = color == undefined || point.meta.color == color; + const soilLevelFilter = atSoilLevel == undefined + || point.meta.at_soil_level == atSoilLevel + || (atSoilLevel == "false" && point.meta.at_soil_level == undefined); + + return stageFilter + && slugFilter + && minRadiusFilter + && maxRadiusFilter + && minAgeFilter + && maxAgeFilter + && colorFilter + && soilLevelFilter; +}; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 31aa73a86b..a74f50f095 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -17,6 +17,7 @@ const mockDeviceDefault: DeepPartial = { writePin: jest.fn(() => Promise.resolve()), home: jest.fn(() => Promise.resolve()), findHome: jest.fn(() => Promise.resolve()), + calibrate: jest.fn(() => Promise.resolve()), sync: jest.fn(() => Promise.resolve()), send: jest.fn(() => Promise.resolve()), readStatus: jest.fn(() => Promise.resolve()), @@ -42,6 +43,16 @@ jest.mock("../../redux/store", () => ({ }, })); +jest.mock("../../demo/lua_runner", () => ({ + runDemoSequence: jest.fn(), + runDemoLuaCode: jest.fn(), + csToLua: jest.fn(), +})); + +jest.mock("../../demo/lua_runner/actions", () => ({ + eStop: jest.fn(), +})); + import * as actions from "../actions"; import { fakeFirmwareConfig, fakeFbosConfig, @@ -52,8 +63,10 @@ import axios from "axios"; import { success, error, warning, info } from "../../toast/toast"; import { edit, save } from "../../api/crud"; import { DeepPartial } from "../../redux/interfaces"; -import { Farmbot } from "farmbot"; +import { EmergencyLock, Execute, Farmbot, Wait } from "farmbot"; import { Path } from "../../internal_urls"; +import { csToLua, runDemoLuaCode, runDemoSequence } from "../../demo/lua_runner"; +import { eStop } from "../../demo/lua_runner/actions"; const replaceDeviceWith = async (d: DeepPartial, cb: Function) => { jest.clearAllMocks(); @@ -63,6 +76,10 @@ const replaceDeviceWith = async (d: DeepPartial, cb: Function) => { }; describe("sendRPC()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls sendRPC", async () => { await actions.sendRPC({ kind: "sync", args: {} }); expect(mockDevice.current.send).toHaveBeenCalledWith({ @@ -71,6 +88,37 @@ describe("sendRPC()", () => { body: [{ kind: "sync", args: {} }], }); }); + + it("calls sendRPC on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + const cmd: Wait = { kind: "wait", args: { milliseconds: 1000 } }; + await actions.sendRPC(cmd); + expect(mockDevice.current.send).not.toHaveBeenCalled(); + expect(csToLua).toHaveBeenCalledWith(cmd); + }); + + it("calls sendRPC on demo accounts: estop", async () => { + localStorage.setItem("myBotIs", "online"); + const cmd: EmergencyLock = { kind: "emergency_lock", args: {} }; + await actions.sendRPC(cmd); + expect(mockDevice.current.send).not.toHaveBeenCalled(); + expect(csToLua).not.toHaveBeenCalled(); + expect(eStop).toHaveBeenCalled(); + }); + + it("calls sendRPC on demo accounts: execute", async () => { + localStorage.setItem("myBotIs", "online"); + const cmd: Execute = { kind: "execute", args: { sequence_id: 1 }, body: [] }; + await actions.sendRPC(cmd); + expect(mockDevice.current.send).not.toHaveBeenCalled(); + expect(csToLua).not.toHaveBeenCalled(); + expect(runDemoLuaCode).not.toHaveBeenCalled(); + expect(runDemoSequence).toHaveBeenCalledWith( + expect.any(Object), + 1, + [], + ); + }); }); describe("readStatus()", () => { @@ -142,17 +190,38 @@ describe("flashFirmware()", () => { }); describe("emergencyLock() / emergencyUnlock", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + window.confirm = () => false; + }); + it("calls emergencyLock", () => { actions.emergencyLock(); expect(mockDevice.current.emergencyLock).toHaveBeenCalled(); }); + it("calls emergencyLock on demo account", () => { + localStorage.setItem("myBotIs", "online"); + actions.emergencyLock(); + expect(mockDevice.current.emergencyLock).not.toHaveBeenCalled(); + expect(runDemoLuaCode).not.toHaveBeenCalled(); + expect(eStop).toHaveBeenCalled(); + }); + it("calls emergencyUnlock", () => { window.confirm = () => true; actions.emergencyUnlock(); expect(mockDevice.current.emergencyUnlock).toHaveBeenCalled(); }); + it("calls emergencyUnlock on demo account", () => { + window.confirm = () => true; + localStorage.setItem("myBotIs", "online"); + actions.emergencyUnlock(); + expect(mockDevice.current.emergencyUnlock).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("emergency_unlock()"); + }); + it("doesn't call emergencyUnlock", () => { window.confirm = () => false; actions.emergencyUnlock(); @@ -194,6 +263,10 @@ describe("sync()", () => { }); describe("execSequence()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("handles normal errors", () => { const errorThrower: DeepPartial = { execSequence: jest.fn(() => Promise.reject(new Error("yolo"))) @@ -230,6 +303,17 @@ describe("execSequence()", () => { expect(success).toHaveBeenCalled(); }); + it("calls execSequence on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.execSequence(1); + expect(mockDevice.current.execSequence).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(runDemoSequence).toHaveBeenCalledWith( + expect.any(Object), + 1, + undefined); + }); + it("implodes when executing unsaved sequences", () => { expect(() => actions.execSequence(undefined)).toThrow(); expect(mockDevice.current.execSequence).not.toHaveBeenCalled(); @@ -237,6 +321,10 @@ describe("execSequence()", () => { }); describe("takePhoto()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls takePhoto", async () => { await actions.takePhoto(); expect(mockDevice.current.takePhoto).toHaveBeenCalled(); @@ -245,6 +333,14 @@ describe("takePhoto()", () => { expect(error).not.toHaveBeenCalled(); }); + it("calls takePhoto on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.takePhoto(); + expect(mockDevice.current.takePhoto).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("take_photo()"); + }); + it("calls takePhoto: error", async () => { mockDevice.current.takePhoto = jest.fn(() => Promise.reject("error")); await actions.takePhoto(); @@ -333,11 +429,25 @@ describe("updateMCU()", () => { }); describe("moveRelative()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls moveRelative", async () => { await actions.moveRelative({ x: 1, y: 0, z: 0 }); expect(mockDevice.current.moveRelative) .toHaveBeenCalledWith({ x: 1, y: 0, z: 0 }); expect(success).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("calls moveRelative on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.moveRelative({ x: 1, y: 0, z: 0 }); + expect(mockDevice.current.moveRelative).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("move_relative(1, 0, 0)"); }); it("shows lock message", () => { @@ -350,15 +460,53 @@ describe("moveRelative()", () => { }); describe("moveAbsolute()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls moveAbsolute", async () => { await actions.moveAbsolute({ x: 1, y: 0, z: 0 }); expect(mockDevice.current.moveAbsolute) .toHaveBeenCalledWith({ x: 1, y: 0, z: 0 }); expect(success).not.toHaveBeenCalled(); }); + + it("calls moveAbsolute on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.moveAbsolute({ x: 1, y: 0, z: 0 }); + expect(mockDevice.current.moveAbsolute).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("move_absolute(1, 0, 0)"); + }); }); describe("move()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + + const BODY = [{ + kind: "axis_overwrite", + args: { + axis: "x", + axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, + } + }, + { + kind: "axis_overwrite", + args: { + axis: "y", + axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, + } + }, + { + kind: "axis_overwrite", + args: { + axis: "z", + axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, + } + }]; + it("calls move", async () => { await actions.move({ x: 1, y: 0, z: 0 }); expect(mockDevice.current.send) @@ -368,27 +516,7 @@ describe("move()", () => { body: [{ kind: "move", args: {}, - body: [{ - kind: "axis_overwrite", - args: { - axis: "x", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "y", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "z", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }], + body: BODY, }], }); expect(success).not.toHaveBeenCalled(); @@ -403,48 +531,29 @@ describe("move()", () => { body: [{ kind: "move", args: {}, - body: [{ - kind: "axis_overwrite", - args: { - axis: "x", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "y", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "z", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "speed_overwrite", - args: { - axis: "x", - speed_setting: { kind: "numeric", args: { number: 50 } }, - } - }, - { - kind: "speed_overwrite", - args: { - axis: "y", - speed_setting: { kind: "numeric", args: { number: 50 } }, - } - }, - { - kind: "speed_overwrite", - args: { - axis: "z", - speed_setting: { kind: "numeric", args: { number: 50 } }, - } - }, + body: [ + ...BODY, + { + kind: "speed_overwrite", + args: { + axis: "x", + speed_setting: { kind: "numeric", args: { number: 50 } }, + } + }, + { + kind: "speed_overwrite", + args: { + axis: "y", + speed_setting: { kind: "numeric", args: { number: 50 } }, + } + }, + { + kind: "speed_overwrite", + args: { + axis: "z", + speed_setting: { kind: "numeric", args: { number: 50 } }, + } + }, ], }], }); @@ -460,32 +569,25 @@ describe("move()", () => { body: [{ kind: "move", args: {}, - body: [{ - kind: "axis_overwrite", - args: { - axis: "x", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "y", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { - kind: "axis_overwrite", - args: { - axis: "z", - axis_operand: { kind: "coordinate", args: { x: 1, y: 0, z: 0 } }, - } - }, - { kind: "safe_z", args: {} }] + body: [ + ...BODY, + { kind: "safe_z", args: {} }] }], }); expect(success).not.toHaveBeenCalled(); }); + + it("calls move on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.move({ x: 1, y: 0, z: 0 }); + expect(mockDevice.current.send).not.toHaveBeenCalled(); + expect(csToLua).toHaveBeenCalledWith({ + kind: "move", + args: {}, + body: BODY, + }); + expect(success).not.toHaveBeenCalled(); + }); }); describe("pinToggle()", () => { @@ -494,20 +596,16 @@ describe("pinToggle()", () => { }); it("calls togglePin", async () => { - await actions.pinToggle(5)(jest.fn()); + await actions.pinToggle(5); expect(mockDevice.current.togglePin).toHaveBeenCalledWith({ pin_number: 5 }); expect(success).not.toHaveBeenCalled(); }); it("toggles demo account pin", () => { localStorage.setItem("myBotIs", "online"); - const dispatch = jest.fn(); - actions.pinToggle(5)(dispatch); + actions.pinToggle(5); expect(mockDevice.current.togglePin).not.toHaveBeenCalled(); - expect(dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_TOGGLE_PIN, - payload: 5 - }); + expect(runDemoLuaCode).toHaveBeenCalledWith("toggle_pin(5)"); }); }); @@ -532,21 +630,63 @@ describe("writePin()", () => { }); describe("moveToHome()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls home", async () => { await actions.moveToHome("x"); expect(mockDevice.current.home) .toHaveBeenCalledWith({ axis: "x", speed: 100 }); expect(success).not.toHaveBeenCalled(); }); + + it("calls home on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.moveToHome("x"); + expect(mockDevice.current.home).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("go_to_home(\"x\")"); + }); }); describe("findHome()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls find_home", async () => { await actions.findHome("all"); expect(mockDevice.current.findHome) .toHaveBeenCalledWith({ axis: "all", speed: 100 }); expect(success).not.toHaveBeenCalled(); }); + + it("calls find_home on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.findHome("all"); + expect(mockDevice.current.findHome).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("find_home(\"all\")"); + }); +}); + +describe("findAxisLength()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + + it("calls find_axis_length", async () => { + await actions.findAxisLength("x"); + expect(mockDevice.current.calibrate) + .toHaveBeenCalledWith({ axis: "x" }); + expect(success).not.toHaveBeenCalled(); + }); + + it("calls find_home on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.findAxisLength("x"); + expect(mockDevice.current.calibrate).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("find_axis_length(\"x\")"); + }); }); describe("isLog()", () => { diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index 178fbd1120..d40c6ec492 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -180,4 +180,72 @@ describe("botReducer", () => { const r = botReducer(state, action); expect(r.hardware.pins).toEqual({ 13: { value: 0, mode: 0 } }); }); + + it("writes demo pin: digital", () => { + const state = initialState(); + const action = { + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 13, mode: "digital", value: 1 }, + }; + const r = botReducer(state, action); + expect(r.hardware.pins).toEqual({ 13: { value: 1, mode: 0 } }); + }); + + it("writes demo pin: analog", () => { + const state = initialState(); + const action = { + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 13, mode: "analog", value: 1 }, + }; + const r = botReducer(state, action); + expect(r.hardware.pins).toEqual({ 13: { value: 1, mode: 1 } }); + }); + + it("sets position", () => { + const state = initialState(); + const action = { + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 2, z: 3 }, + }; + const r = botReducer(state, action); + expect(r.hardware.location_data.position).toEqual({ x: 1, y: 2, z: 3 }); + }); + + it("set job progress", () => { + const state = initialState(); + const action = { + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { percent: 1, status: "working", time: 50 }], + }; + const r = botReducer(state, action); + expect(r.hardware.jobs).toEqual({ + job: { + percent: 1, + status: "working", + time: 50, + } + }); + }); + + it("sets emergency stop", () => { + const state = initialState(); + const action = { type: Actions.DEMO_SET_ESTOP, payload: true }; + const r = botReducer(state, action); + expect(r.hardware.informational_settings.locked).toEqual(true); + }); + + it("unsets emergency stop", () => { + const state = initialState(); + state.hardware.informational_settings.locked = true; + const action = { type: Actions.DEMO_SET_ESTOP, payload: false }; + const r = botReducer(state, action); + expect(r.hardware.informational_settings.locked).toEqual(false); + }); + + it("sets demo queue length", () => { + const state = initialState(); + const action = { type: Actions.DEMO_SET_QUEUE_LENGTH, payload: 5 }; + const r = botReducer(state, action); + expect(r.demoQueueLength).toEqual(5); + }); }); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 32db3a0fc9..a16539179f 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -18,6 +18,7 @@ import { Xyz, AxisOverwrite, RpcRequestBodyItem, + Move, } from "farmbot"; import { oneOf, versionOK, trim } from "../util"; import { Actions, Content, DeviceSetting } from "../constants"; @@ -35,6 +36,8 @@ import { ToastOptions } from "../toast/interfaces"; import { forceOnline } from "./must_be_online"; import { store } from "../redux/store"; import { linkToSetting } from "../settings/maybe_highlight"; +import { runDemoLuaCode, runDemoSequence, csToLua } from "../demo/lua_runner"; +import { eStop } from "../demo/lua_runner/actions"; const ON = 1, OFF = 0; export type ConfigKey = keyof McuParams; @@ -82,7 +85,19 @@ const maybeAlertLocked = () => /** Send RPC. */ export function sendRPC(command: RpcRequestBodyItem) { - maybeNoop(); + if (forceOnline()) { + if (command.kind == "execute") { + runDemoSequence( + store.getState().resources.index, + command.args.sequence_id, + command.body); + } else if (command.kind == "emergency_lock") { + eStop(); + } else { + runDemoLuaCode(csToLua(command)); + } + return; + } getDevice() .send(rpcRequest([command])) .then(maybeNoop, commandErr()); @@ -160,7 +175,10 @@ export function flashFirmware(firmwareName: FirmwareHardware) { export function emergencyLock() { const noun = t("Emergency stop"); - maybeNoop(); + if (forceOnline()) { + eStop(); + return; + } getDevice() .emergencyLock() .then(commandOK(noun), commandErr(noun)); @@ -169,7 +187,10 @@ export function emergencyLock() { export function emergencyUnlock(force = false) { const noun = t("Emergency unlock"); if (force || confirm(t("Are you sure you want to unlock the device?"))) { - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode("emergency_unlock()"); + return; + } getDevice() .emergencyUnlock() .then(commandOK(noun), commandErr(noun)); @@ -205,6 +226,10 @@ export function execSequence( ) { const noun = t("Sequence execution"); if (sequenceId) { + if (forceOnline()) { + runDemoSequence(store.getState().resources.index, sequenceId, bodyVariables); + return; + } commandOK(noun)(); return getDevice() .execSequence(sequenceId, bodyVariables) @@ -221,7 +246,10 @@ export function execSequence( } export function takePhoto() { - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode("take_photo()"); + return; + } getDevice().takePhoto() .then(commandOK("", Content.PROCESSING_PHOTO)) .catch(() => error(t("Error taking photo"))); @@ -336,7 +364,10 @@ export function settingToggle( } export function moveRelative(props: MoveRelProps) { - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode(`move_relative(${props.x}, ${props.y}, ${props.z})`); + return; + } maybeAlertLocked(); return getDevice() .moveRelative(props) @@ -345,7 +376,10 @@ export function moveRelative(props: MoveRelProps) { export function moveAbsolute(props: MoveRelProps) { const noun = t("Absolute movement"); - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode(`move_absolute(${props.x}, ${props.y}, ${props.z})`); + return; + } maybeAlertLocked(); return getDevice() .moveAbsolute(props) @@ -354,7 +388,6 @@ export function moveAbsolute(props: MoveRelProps) { export function move(props: MoveProps) { const noun = t("Movement"); - maybeNoop(); maybeAlertLocked(); const safeZ: SafeZ = { kind: "safe_z", args: {} }; const speedOverwrite = (axis: Xyz, speed: number): SpeedOverwrite => ({ @@ -388,26 +421,26 @@ export function move(props: MoveProps) { ...(props.speed ? [speedOverwrite("z", props.speed)] : []), ...(props.safeZ ? [safeZ] : []), ]; + const cmd: Move = { kind: "move", args: {}, body }; + if (forceOnline()) { + runDemoLuaCode(csToLua(cmd)); + return; + } return getDevice() - .send(rpcRequest([{ kind: "move", args: {}, body }])) + .send(rpcRequest([cmd])) .then(maybeNoop, commandErr(noun)); } export function pinToggle(pin_number: number) { - return function (dispatch: Function) { - const noun = t("Toggle pin"); - if (forceOnline()) { - dispatch({ - type: Actions.DEMO_TOGGLE_PIN, - payload: pin_number, - }); - return; - } - maybeAlertLocked(); - return getDevice() - .togglePin({ pin_number }) - .then(maybeNoop, commandErr(noun)); - }; + const noun = t("Toggle pin"); + if (forceOnline()) { + runDemoLuaCode(`toggle_pin(${pin_number})`); + return; + } + maybeAlertLocked(); + return getDevice() + .togglePin({ pin_number }) + .then(maybeNoop, commandErr(noun)); } export function readPin( @@ -432,8 +465,11 @@ export function writePin( } export function moveToHome(axis: Axis) { + if (forceOnline()) { + runDemoLuaCode(`go_to_home("${axis}")`); + return; + } const noun = t("'Move To Home' command"); - maybeNoop(); maybeAlertLocked(); getDevice() .home({ axis, speed: CONFIG_DEFAULTS.speed }) @@ -442,7 +478,10 @@ export function moveToHome(axis: Axis) { export function findHome(axis: Axis) { const noun = t("'Find Home' command"); - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode(`find_home("${axis}")`); + return; + } maybeAlertLocked(); getDevice() .findHome({ axis, speed: CONFIG_DEFAULTS.speed }) @@ -459,8 +498,11 @@ export function setHome(axis: Axis) { export function findAxisLength(axis: Axis) { const noun = t("'Find Axis Length' command"); - maybeNoop(); maybeAlertLocked(); + if (forceOnline()) { + runDemoLuaCode(`find_axis_length("${axis}")`); + return; + } getDevice() .calibrate({ axis }) .catch(commandErr(noun)); diff --git a/frontend/devices/connectivity/diagram.tsx b/frontend/devices/connectivity/diagram.tsx index f0a58d1de3..90bee4e7cf 100644 --- a/frontend/devices/connectivity/diagram.tsx +++ b/frontend/devices/connectivity/diagram.tsx @@ -62,7 +62,10 @@ export function getTextPosition( } export function nodeLabel( - label: string, node: DiagramNodes, anchor = "middle"): React.ReactNode { + label: string, + node: DiagramNodes, + anchor: "start" | "middle" | "end" = "middle", +): React.ReactNode { const position = getTextPosition(node); return {label} diff --git a/frontend/devices/interfaces.ts b/frontend/devices/interfaces.ts index ad89422bab..d472ab4814 100644 --- a/frontend/devices/interfaces.ts +++ b/frontend/devices/interfaces.ts @@ -106,6 +106,7 @@ export interface BotState { connectivity: ConnectionState; needVersionCheck: boolean; alreadyToldUserAboutMalformedMsg: boolean; + demoQueueLength: number; } /** Status registers for the bot's status */ diff --git a/frontend/devices/jobs.tsx b/frontend/devices/jobs.tsx index afbfea5d9c..241365fda1 100644 --- a/frontend/devices/jobs.tsx +++ b/frontend/devices/jobs.tsx @@ -153,7 +153,7 @@ const Job = (props: JobProps) => (job: JobProgressWithTitle) => { ? { width: `${percent}%`, background: color } : {}} /> - {job.status} + {job.status} {props.more && {job.time ? formatTime(moment(job.time), props.timeSettings) diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index 8e04e5b998..22eb20ff93 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -13,6 +13,7 @@ import { } from "../connectivity/reducer"; import { versionOK } from "../util"; import { updateMotorHistoryArray } from "../controls/move/motor_position_plot"; +import { PercentageProgress, Xyz } from "farmbot"; const afterEach = (state: BotState, a: ReduxAction<{}>) => { state.connectivity = connectivityReducer(state.connectivity, a); @@ -74,6 +75,7 @@ export const initialState = (): BotState => ({ }, needVersionCheck: true, alreadyToldUserAboutMalformedMsg: false, + demoQueueLength: 0, }); export const botReducer = generateReducer(initialState()) @@ -136,6 +138,32 @@ export const botReducer = generateReducer(initialState()) pin.value = Number(!pin.value); return s; }) + .add<{ pin: number, mode: string, value: number }>(Actions.DEMO_WRITE_PIN, + (s, { payload }) => { + const mode = payload.mode.toLowerCase() == "analog" ? 1 : 0; + s.hardware.pins[payload.pin] = { mode, value: payload.value }; + return s; + }) + .add>(Actions.DEMO_SET_POSITION, (s, { payload }) => { + s.hardware.location_data.position = payload; + return s; + }) + .add<[string, PercentageProgress]>(Actions.DEMO_SET_JOB_PROGRESS, + (s, { payload }) => { + s.hardware.jobs[payload[0]] = payload[1]; + return s; + }) + .add(Actions.DEMO_SET_ESTOP, (s, { payload }) => { + s.hardware.informational_settings.locked = payload; + s.hardware.pins = {}; + s.hardware.jobs = {}; + s.demoQueueLength = 0; + return s; + }) + .add(Actions.DEMO_SET_QUEUE_LENGTH, (s, { payload }) => { + s.demoQueueLength = payload; + return s; + }) .add(Actions.PING_OK, (s) => { // Going from "down" to "up" const currentState = s.connectivity.uptime["bot.mqtt"]; diff --git a/frontend/devices/timezones/__tests__/guess_timezone_test.ts b/frontend/devices/timezones/__tests__/guess_timezone_test.ts index abbcbe7090..ccab0fe04b 100644 --- a/frontend/devices/timezones/__tests__/guess_timezone_test.ts +++ b/frontend/devices/timezones/__tests__/guess_timezone_test.ts @@ -7,6 +7,7 @@ import { inferTimezone, maybeSetTimezone } from "../guess_timezone"; import { get, set } from "lodash"; import { fakeDevice } from "../../../__test_support__/resource_index_builder"; import { edit, save } from "../../../api/crud"; +import { Actions } from "../../../constants"; describe("inferTimezone", () => { it("returns the timezone provided, if possible", () => { @@ -33,6 +34,22 @@ describe("maybeSetTimezone()", () => { const dispatch = jest.fn(); maybeSetTimezone(dispatch, device); expect(dispatch).not.toHaveBeenCalled(); + expect(edit).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it("doesn't set timezone, but sets 3D time", () => { + localStorage.setItem("myBotIs", "online"); + const device = fakeDevice(); + device.body.timezone = "fake timezone"; + const dispatch = jest.fn(); + maybeSetTimezone(dispatch, device); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_3D_TIME, + payload: "12:00", + }); + expect(edit).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); }); it("sets timezone", () => { @@ -56,6 +73,10 @@ describe("maybeSetTimezone()", () => { timezone: "UTC", lat: 0, lng: -90, }); expect(save).toHaveBeenCalledWith(device.uuid); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_3D_TIME, + payload: "12:00", + }); spy.mockRestore(); }); }); diff --git a/frontend/devices/timezones/guess_timezone.ts b/frontend/devices/timezones/guess_timezone.ts index 615c42aca3..9cf04af1ec 100644 --- a/frontend/devices/timezones/guess_timezone.ts +++ b/frontend/devices/timezones/guess_timezone.ts @@ -34,9 +34,11 @@ export function maybeSetTimezone(dispatch: Function, device: TaggedDevice) { if (forceOnline()) { update.lng = -(new Date().getTimezoneOffset()) / 4; update.lat = 0; - dispatch({ type: Actions.SET_3D_TIME, payload: "12:00" }); } dispatch(edit(device, update)); dispatch(save(device.uuid)); } + if (forceOnline()) { + dispatch({ type: Actions.SET_3D_TIME, payload: "12:00" }); + } } diff --git a/frontend/farm_designer/__tests__/location_info_test.tsx b/frontend/farm_designer/__tests__/location_info_test.tsx index ab0d1bb601..065837d52d 100644 --- a/frontend/farm_designer/__tests__/location_info_test.tsx +++ b/frontend/farm_designer/__tests__/location_info_test.tsx @@ -9,6 +9,7 @@ import { BooleanSetting } from "../../session_keys"; import { fakeTimeSettings } from "../../__test_support__/fake_time_settings"; import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; import { + fakeFbosConfig, fakeImage, fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeWebAppConfig, } from "../../__test_support__/fake_state/resources"; import { tagAsSoilHeight } from "../../points/soil_height"; @@ -37,6 +38,7 @@ describe("", () => { farmwareEnvs: [], arduinoBusy: false, movementState: fakeMovementState(), + defaultAxisOrder: "safe_z", }); it("renders empty panel", () => { @@ -163,10 +165,17 @@ describe("", () => { describe("mapStateToProps()", () => { it("returns props", () => { const state = fakeState(); - state.resources = buildResourceIndex([fakeWebAppConfig()]); + state.resources = buildResourceIndex([fakeWebAppConfig(), fakeFbosConfig()]); const props = mapStateToProps(state); expect(props.getConfigValue(BooleanSetting.xy_swap)).toEqual(false); }); + + it("handles missing config", () => { + const state = fakeState(); + state.resources = buildResourceIndex([]); + const props = mapStateToProps(state); + expect(props.getConfigValue(BooleanSetting.xy_swap)).toEqual(undefined); + }); }); describe("", () => { diff --git a/frontend/farm_designer/__tests__/move_to_test.tsx b/frontend/farm_designer/__tests__/move_to_test.tsx index 304ac21879..2d54c4e8dd 100644 --- a/frontend/farm_designer/__tests__/move_to_test.tsx +++ b/frontend/farm_designer/__tests__/move_to_test.tsx @@ -9,6 +9,10 @@ jest.mock("../../ui/popover", () => ({ Popover: ({ target, content }: PopoverProps) =>
{target}{content}
, })); +jest.mock("../../settings/dev/dev_support", () => ({ + DevSettings: { allOrderOptionsEnabled: () => false }, +})); + import React from "react"; import { mount, shallow } from "enzyme"; import { render, screen, fireEvent } from "@testing-library/react"; @@ -32,12 +36,13 @@ describe("", () => { botOnline: true, locked: false, dispatch: jest.fn(), + defaultAxisOrder: "safe_z", }); it("moves to location: custom z value", () => { const wrapper = mount(); wrapper.setState({ z: 50 }); - wrapper.find("button").simulate("click"); + wrapper.find("button").at(0).simulate("click"); expect(move).toHaveBeenCalledWith({ x: 1, y: 2, z: 50, speed: 100, safeZ: false, }); @@ -58,10 +63,14 @@ describe("", () => { }); it("changes safe z value", () => { - const wrapper = shallow(); - wrapper.findWhere(n => "onChange" in n.props()).at(2) - .simulate("change"); - expect(wrapper.state().safeZ).toEqual(true); + render(); + expect(screen.queryByText("Safe Z")).not.toBeInTheDocument(); + const dropdown = screen.getByRole("button", { name: "Use default (Safe Z)" }); + fireEvent.click(dropdown); + expect(screen.getAllByText("Safe Z").length).toEqual(1); + const item = screen.getByRole("menuitem", { name: "Safe Z" }); + fireEvent.click(item); + expect(screen.getAllByText("Safe Z").length).toEqual(2); }); it("fills in some missing values", () => { @@ -69,7 +78,7 @@ describe("", () => { p.chosenLocation = { x: 1, y: undefined, z: undefined }; const wrapper = mount(); expect(wrapper.find("input").at(1).props().value).toEqual("---"); - wrapper.find("button").simulate("click"); + wrapper.find("button").at(0).simulate("click"); expect(move).toHaveBeenCalledWith({ x: 1, y: 20, z: 30, speed: 100, safeZ: false, }); @@ -81,7 +90,7 @@ describe("", () => { p.currentBotLocation = { x: undefined, y: undefined, z: undefined }; const wrapper = mount(); expect(wrapper.find("input").at(1).props().value).toEqual("---"); - wrapper.find("button").simulate("click"); + wrapper.find("button").at(0).simulate("click"); expect(move).toHaveBeenCalledWith({ x: 0, y: 0, z: 0, speed: 100, safeZ: false, }); @@ -91,7 +100,7 @@ describe("", () => { const p = fakeProps(); p.botOnline = false; const wrapper = mount(); - expect(wrapper.find("button").hasClass("pseudo-disabled")).toBeTruthy(); + expect(wrapper.find("button").at(0).hasClass("pseudo-disabled")).toBeTruthy(); }); }); diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index b550716af9..f577b9ecb1 100644 --- a/frontend/farm_designer/location_info.tsx +++ b/frontend/farm_designer/location_info.tsx @@ -26,7 +26,7 @@ import { } from "./move_to"; import { Actions } from "../constants"; import { useNavigate } from "react-router"; -import { distance } from "../point_groups/paths"; +import { distance } from "../point_groups/other_sort_methods"; import { isUndefined, round, sortBy, sum } from "lodash"; import { PlantInventoryItem } from "../plants/plant_inventory_item"; import { PointInventoryItem } from "../points/point_inventory_item"; @@ -46,6 +46,7 @@ import { PhotoFooter } from "../photos/images/photos"; import { Path } from "../internal_urls"; import { NavigationContext } from "../routes_helpers"; import { DrawnPointPayl } from "./interfaces"; +import { getFbosConfig } from "../resources/getters"; export const mapStateToProps = (props: Everything): LocationInfoProps => ({ chosenLocation: props.resources.consumers.farm_designer.chosenLocation, @@ -67,6 +68,7 @@ export const mapStateToProps = (props: Everything): LocationInfoProps => ({ timeSettings: maybeGetTimeSettings(props.resources.index), arduinoBusy: props.bot.hardware.informational_settings.busy, movementState: props.app.movement, + defaultAxisOrder: getFbosConfig(props.resources.index)?.body.default_axis_order, }); export interface LocationInfoProps { @@ -87,6 +89,7 @@ export interface LocationInfoProps { farmwareEnvs: TaggedFarmwareEnv[]; arduinoBusy: boolean; movementState: MovementState; + defaultAxisOrder: string | undefined; } export class RawLocationInfo extends React.Component { @@ -142,6 +145,7 @@ export class RawLocationInfo extends React.Component { graphic={EmptyStateGraphic.points}>
{ const navigate = useNavigate(); return
{ - switch (groupSortType) { - default: return sortGroupBy(groupSortType, groupPoints); - } -}; - const sortedPointCoordinates = ( group: TaggedPointGroup | undefined, groupPoints: TaggedPoint[], @@ -36,7 +25,7 @@ const sortedPointCoordinates = ( ): { x: number, y: number }[] => { if (isUndefined(group)) { return []; } const groupSortType = tryGroupSortType || group.body.sort_type; - return convertToXY(sortGroup(groupSortType, groupPoints)); + return convertToXY(sortGroupBy(groupSortType, groupPoints)); }; interface PointsPathLineProps { diff --git a/frontend/farm_designer/map/layers/points/interpolation_map.tsx b/frontend/farm_designer/map/layers/points/interpolation_map.tsx index ec06ad5c69..d12d071fe4 100644 --- a/frontend/farm_designer/map/layers/points/interpolation_map.tsx +++ b/frontend/farm_designer/map/layers/points/interpolation_map.tsx @@ -6,7 +6,7 @@ import { import { MapTransformProps } from "../../interfaces"; import { transformXY } from "../../util"; import { isUndefined, range, round, sum } from "lodash"; -import { distance, findNearest } from "../../../../point_groups/paths"; +import { distance, findNearest } from "../../../../point_groups/other_sort_methods"; import { selectMostRecentPoints } from "../../../location_info"; import { betterCompact } from "../../../../util"; import { t } from "../../../../i18next_wrapper"; diff --git a/frontend/farm_designer/map/layers/tool_slots/tool_label.tsx b/frontend/farm_designer/map/layers/tool_slots/tool_label.tsx index 1b4bcccc3b..d5d6f90255 100644 --- a/frontend/farm_designer/map/layers/tool_slots/tool_label.tsx +++ b/frontend/farm_designer/map/layers/tool_slots/tool_label.tsx @@ -15,7 +15,7 @@ export const textAnchorPosition = ( quadrant: BotOriginQuadrant, xySwap: boolean, gantryMounted: boolean, -): { x: number, y: number, anchor: string } => { +): { x: number, y: number, anchor: "start" | "middle" | "end" } => { const rawAnchor = () => { const noDirection = !pulloutDirection || gantryMounted; const noDirectionXY = xySwap diff --git a/frontend/farm_designer/map/profile/content.tsx b/frontend/farm_designer/map/profile/content.tsx index 9fc1039b18..4648ca24ff 100644 --- a/frontend/farm_designer/map/profile/content.tsx +++ b/frontend/farm_designer/map/profile/content.tsx @@ -131,7 +131,7 @@ const LabeledHorizontalLine = (props: LabeledHorizontalLineProps) => strokeDasharray={props.dashed ? 10 : undefined} x1={0} y1={props.y} x2={props.width} y2={props.y} /> {props.expanded && {props.label} } diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx index f43f686b9e..84e28412ec 100644 --- a/frontend/farm_designer/move_to.tsx +++ b/frontend/farm_designer/move_to.tsx @@ -8,7 +8,10 @@ import { isNumber, isUndefined, sum } from "lodash"; import { Actions, Content } from "../constants"; import { AxisNumberProperty } from "./map/interfaces"; import { t } from "../i18next_wrapper"; -import { SafeZCheckbox } from "../sequences/step_tiles/tile_computed_move/safe_z"; +import { + AxisOrderInputRow, + getNewAxisOrderState, +} from "../sequences/step_tiles/tile_computed_move/axis_order"; import { Position, Slider } from "@blueprintjs/core"; import { Path } from "../internal_urls"; import { setMovementStateFromPosition } from "../connectivity/log_handlers"; @@ -21,6 +24,9 @@ import { StringSetting } from "../session_keys"; import { MovementState } from "../interfaces"; import { getUrlQuery } from "../util"; import { setPanelOpen } from "./panel_header"; +import { + AxisGrouping, AxisRoute, +} from "../sequences/step_tiles/tile_computed_move/interfaces"; export interface MoveToFormProps { chosenLocation: BotPosition; @@ -28,16 +34,25 @@ export interface MoveToFormProps { botOnline: boolean; locked: boolean; dispatch: Function; + defaultAxisOrder: string | undefined; } interface MoveToFormState { z: number | undefined; safeZ: boolean; + axisGrouping: AxisGrouping; + axisRoute: AxisRoute; speed: number; } export class MoveToForm extends React.Component { - state = { z: this.props.chosenLocation.z, safeZ: false, speed: 100 }; + state = { + z: this.props.chosenLocation.z, + safeZ: false, + axisGrouping: undefined, + axisRoute: undefined, + speed: 100, + }; get vector(): { x: number, y: number, z: number } { const { chosenLocation } = this.props; @@ -93,8 +108,13 @@ export class MoveToForm extends React.Component this.setState({ speed })} /> - this.setState({ safeZ: !this.state.safeZ })} /> + + this.setState({ ...this.state, ...getNewAxisOrderState(ddi) })} />
; } } diff --git a/frontend/hacks.d.ts b/frontend/hacks.d.ts index 5bc9e62cf6..abad41ba85 100644 --- a/frontend/hacks.d.ts +++ b/frontend/hacks.d.ts @@ -29,3 +29,5 @@ declare namespace jest { } declare var mockNavigate: jest.Mock; + +declare module 'fengari-web'; diff --git a/frontend/help/header.tsx b/frontend/help/header.tsx index f173722d8c..3b9a06f48b 100644 --- a/frontend/help/header.tsx +++ b/frontend/help/header.tsx @@ -4,7 +4,6 @@ import { NavigateFunction, useNavigate } from "react-router"; import { toggleHotkeyHelpOverlay } from "../hotkeys"; import { t } from "../i18next_wrapper"; import { FilePath, Icon, Path } from "../internal_urls"; -import { store } from "../redux/store"; import { isMobile } from "../screen_size"; interface Page { @@ -57,7 +56,7 @@ const maybeAddHotkeysMenuItem = (): [string, Page][] => ? [["hotkeys", { title: t("Hotkeys"), fa_icon: "fa-keyboard-o", - onClick: toggleHotkeyHelpOverlay(store.dispatch), + onClick: toggleHotkeyHelpOverlay, }]] : []; diff --git a/frontend/hotkeys.tsx b/frontend/hotkeys.tsx index 4c7f681d35..3534b33a80 100644 --- a/frontend/hotkeys.tsx +++ b/frontend/hotkeys.tsx @@ -1,7 +1,7 @@ import React from "react"; import { getLinks } from "./nav/nav_links"; import { sync } from "./devices/actions"; -import { HotkeyConfig, useHotkeys, HotkeysDialog2 } from "@blueprintjs/core"; +import { HotkeyConfig, useHotkeys } from "@blueprintjs/core"; import { getPanelPath, PANEL_BY_SLUG, setPanelOpen, } from "./farm_designer/panel_header"; @@ -9,7 +9,6 @@ import { t } from "./i18next_wrapper"; import { store } from "./redux/store"; import { save } from "./api/crud"; import { Path } from "./internal_urls"; -import { Actions } from "./constants"; import { NavigateFunction, useNavigate } from "react-router"; import { DesignerState } from "./farm_designer/interfaces"; import { isUndefined } from "lodash"; @@ -19,7 +18,6 @@ type HotkeyConfigs = Record; export interface HotKeysProps { dispatch: Function; - hotkeyGuide: boolean; designer: DesignerState; } @@ -130,8 +128,9 @@ export const hotkeysWithActions = (props: HotkeysWithActionsProps) => { return list; }; -export const toggleHotkeyHelpOverlay = (dispatch: Function) => () => - dispatch({ type: Actions.TOGGLE_HOTKEY_GUIDE, payload: undefined }); +export const toggleHotkeyHelpOverlay = () => + document.dispatchEvent(new KeyboardEvent("keydown", + { key: "?", shiftKey: true, bubbles: true })); export const HotKeys = (props: HotKeysProps) => { const navigate = useNavigate(); @@ -147,8 +146,5 @@ export const HotKeys = (props: HotKeysProps) => { { showDialogKeyCombo: undefined }); return
-
; }; diff --git a/frontend/logs/__tests__/index_test.tsx b/frontend/logs/__tests__/index_test.tsx index b9a46930f3..1bb4e0911c 100644 --- a/frontend/logs/__tests__/index_test.tsx +++ b/frontend/logs/__tests__/index_test.tsx @@ -53,7 +53,6 @@ describe("", () => { .map(string => expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase())); verifyFilterState(wrapper, true); - expect(wrapper.text().toLowerCase()).not.toContain("demo"); }); it("handles unknown log type", () => { @@ -65,7 +64,6 @@ describe("", () => { .map(string => expect(wrapper.text().toLowerCase()).toContain(string.toLowerCase())); verifyFilterState(wrapper, true); - expect(wrapper.text().toLowerCase()).not.toContain("demo"); }); it("shows message when logs are loading", () => { @@ -212,14 +210,6 @@ describe("", () => { expect(wrapper.state().searchTerm).toEqual("one"); }); - it("shows demo account log", () => { - localStorage.setItem("myBotIs", "online"); - const p = fakeProps(); - const wrapper = mount(); - expect(wrapper.text().toLowerCase()).toContain("demo"); - localStorage.setItem("myBotIs", ""); - }); - it("shows current logs", () => { const p = fakeProps(); p.bot.hardware.informational_settings.controller_version = "1.2.3"; diff --git a/frontend/logs/index.tsx b/frontend/logs/index.tsx index fcf3d27b4d..b6961bb94f 100644 --- a/frontend/logs/index.tsx +++ b/frontend/logs/index.tsx @@ -13,8 +13,6 @@ import { setWebAppConfigValue } from "../config_storage/actions"; import { NumberConfigKey } from "farmbot/dist/resources/configs/web_app"; import { t } from "../i18next_wrapper"; import { SearchField } from "../ui/search_field"; -import { forceOnline } from "../devices/must_be_online"; -import { demoAccountLog } from "../nav/ticker_list"; import { Actions } from "../constants"; import { Navigate } from "react-router"; import { Path } from "../internal_urls"; @@ -118,8 +116,7 @@ export class LogsPanel extends React.Component> {
{ describe("SEED_DATA_OPTIONS()", () => { it("returns options", () => { mockFeatureBoolean = false; - expect(SEED_DATA_OPTIONS().length).toEqual(15); + expect(SEED_DATA_OPTIONS().length).toEqual(17); }); it("returns more options", () => { diff --git a/frontend/messages/cards.tsx b/frontend/messages/cards.tsx index 9657858e63..b29eb8eacb 100644 --- a/frontend/messages/cards.tsx +++ b/frontend/messages/cards.tsx @@ -167,12 +167,11 @@ const FirmwareChoiceTable = () => - {shouldDisplayFeature(Feature.farmduino_k18) && - - {"Genesis v1.8"} - {"Farmduino"} - {FIRMWARE_CHOICES_DDI["farmduino_k18"].label} - } + + {"Genesis v1.8"} + {"Farmduino"} + {FIRMWARE_CHOICES_DDI["farmduino_k18"].label} + {"Genesis v1.7"} {"Farmduino"} @@ -257,12 +256,8 @@ const FirmwareMissing = (props: FirmwareMissingProps) => ; export const SEED_DATA_OPTIONS = (displayAll = false): DropDownItem[] => [ - ...((shouldDisplayFeature(Feature.farmduino_k18) || displayAll) - ? [{ label: "Genesis v1.8", value: "genesis_1.8" }] - : []), - ...((shouldDisplayFeature(Feature.farmduino_k18) || displayAll) - ? [{ label: "Genesis v1.8 XL", value: "genesis_xl_1.8" }] - : []), + { label: "Genesis v1.8", value: "genesis_1.8" }, + { label: "Genesis v1.8 XL", value: "genesis_xl_1.8" }, { label: "Genesis v1.7", value: "genesis_1.7" }, { label: "Genesis v1.7 XL", value: "genesis_xl_1.7" }, { label: "Genesis v1.6", value: "genesis_1.6" }, diff --git a/frontend/nav/__tests__/index_test.tsx b/frontend/nav/__tests__/index_test.tsx index cdbc04426b..3a6a961f0b 100644 --- a/frontend/nav/__tests__/index_test.tsx +++ b/frontend/nav/__tests__/index_test.tsx @@ -13,6 +13,7 @@ jest.mock("../../devices/actions", () => ({ })); import React from "react"; +import { render, screen } from "@testing-library/react"; import { shallow, mount } from "enzyme"; import { NavBar } from "../index"; import { bot } from "../../__test_support__/fake_state/bot"; @@ -40,6 +41,10 @@ import { mountWithContext } from "../../__test_support__/mount_with_context"; import { ControlsPanel, ControlsPanelProps } from "../../controls/controls"; describe("", () => { + beforeEach(() => { + localStorage.removeItem("myBotIs"); + }); + const fakeProps = (): NavBarProps => ({ timeSettings: fakeTimeSettings(), logs: [], @@ -76,6 +81,12 @@ describe("", () => { expect(wrapper.html()).not.toContain("hover"); }); + it("renders demo account", () => { + localStorage.setItem("myBotIs", "online"); + render(); + expect(screen.getByText("Using a demo account")).toBeInTheDocument(); + }); + it("shows popups as open", () => { const p = fakeProps(); p.appState.popups.connectivity = true; @@ -179,6 +190,15 @@ describe("", () => { expect(wrapper.find(".setup-button").length).toEqual(0); }); + it("displays time travel button", () => { + const p = fakeProps(); + p.getConfigValue = () => true; + p.device.body.lat = 1; + p.device.body.lng = 1; + const wrapper = mount(); + expect(wrapper.find(".time-travel-button").length).toEqual(1); + }); + it("displays navbar visual warning for support tokens", () => { const p = fakeProps(); p.authAud = "staff"; diff --git a/frontend/nav/__tests__/ticker_list_test.tsx b/frontend/nav/__tests__/ticker_list_test.tsx index 5654202f77..0486fb7f80 100644 --- a/frontend/nav/__tests__/ticker_list_test.tsx +++ b/frontend/nav/__tests__/ticker_list_test.tsx @@ -1,10 +1,5 @@ const mockStorj: Dictionary = {}; -let mockDemo = false; -jest.mock("../../devices/must_be_online", () => ({ - forceOnline: () => mockDemo, -})); - import React from "react"; import { mount } from "enzyme"; import { TickerList } from "../ticker_list"; @@ -16,8 +11,6 @@ import { fakeTimeSettings } from "../../__test_support__/fake_time_settings"; import { Actions } from "../../constants"; describe("", () => { - beforeEach(() => { mockDemo = false; }); - const fakeTaggedLog = () => { const log = fakeLog(); log.body.message = "Farmbot is up and Running!"; @@ -69,17 +62,6 @@ describe("", () => { expect(labels.at(1).text()).toEqual("Last seen AUG 2, 7:50PM"); }); - it("shows demo account log message", () => { - mockDemo = true; - const p = fakeProps(); - p.botOnline = false; - const wrapper = mount(); - const labels = wrapper.find("label"); - expect(labels.length).toEqual(2); - expect(labels.at(0).text()).toContain("Using a demo account"); - expect(labels.at(1).text()).toEqual(""); - }); - it("shows empty log message", () => { const p = fakeProps(); p.logs = []; diff --git a/frontend/nav/index.tsx b/frontend/nav/index.tsx index 97d9f7e31e..bf8611b0c7 100644 --- a/frontend/nav/index.tsx +++ b/frontend/nav/index.tsx @@ -5,7 +5,7 @@ import { Popover } from "../ui"; import { updatePageInfo } from "../util"; import { validBotLocationData } from "../util/location"; import { NavLinks } from "./nav_links"; -import { TickerList } from "./ticker_list"; +import { demoAccountLog, TickerList } from "./ticker_list"; import { AdditionalMenu } from "./additional_menu"; import { MobileMenu } from "./mobile_menu"; import { Position } from "@blueprintjs/core"; @@ -17,7 +17,7 @@ import { DiagnosisSaucer } from "../devices/connectivity/diagnosis"; import { maybeSetTimezone } from "../devices/timezones/guess_timezone"; import { BooleanSetting } from "../session_keys"; import { ReadOnlyIcon } from "../read_only_mode"; -import { isBotOnlineFromState } from "../devices/must_be_online"; +import { forceOnline, isBotOnlineFromState } from "../devices/must_be_online"; import { setupProgressString } from "../wizard/data"; import { lastSeenNumber } from "../settings/fbos_settings/last_seen_row"; import { Path } from "../internal_urls"; @@ -34,7 +34,9 @@ import { movementPercentRemaining } from "../farm_designer/move_to"; import { isMobile } from "../screen_size"; import { NavigationContext } from "../routes_helpers"; import { NavigateFunction } from "react-router"; -import { TimeTravelContent, TimeTravelTarget } from "../three_d_garden/time_travel"; +import { + showTimeTravelButton, TimeTravelContent, TimeTravelTarget, +} from "../three_d_garden/time_travel"; export class NavBar extends React.Component> { state: NavBarState = { @@ -60,6 +62,10 @@ export class NavBar extends React.Component> { get isStaff() { return this.props.authAud == "staff"; } + get logs() { + return this.props.logs.concat(forceOnline() ? [demoAccountLog()] : []); + } + toggle = (key: keyof NavBarState) => () => this.setState({ [key]: !this.state[key] }); @@ -81,6 +87,7 @@ export class NavBar extends React.Component> { threeDGarden, designer: this.props.designer, }; + if (!showTimeTravelButton(threeDGarden, common.device)) { return; } return
> { dispatch={this.props.dispatch} bot={this.props.bot} getConfigValue={this.props.getConfigValue} - logs={this.props.logs} + logs={this.logs} jobsPanelState={this.props.appState.jobs} sourceFbosConfig={this.props.sourceFbosConfig} fbosVersion={this.props.device.body.fbos_version} @@ -298,7 +305,7 @@ export class NavBar extends React.Component> { TickerList = () => > { - - + +
diff --git a/frontend/nav/mobile_menu.tsx b/frontend/nav/mobile_menu.tsx index 0ff11961d8..3429619c34 100644 --- a/frontend/nav/mobile_menu.tsx +++ b/frontend/nav/mobile_menu.tsx @@ -1,14 +1,15 @@ import React from "react"; -import { Overlay2, Classes } from "@blueprintjs/core"; +import { Classes } from "@blueprintjs/core"; import { NavLinks } from "./nav_links"; import { MobileMenuProps } from "./interfaces"; +import { Overlay } from "../ui"; const classes = [Classes.CARD, Classes.ELEVATION_4, "mobile-menu"]; export const MobileMenu = (props: MobileMenuProps) => { const isActive = props.mobileMenuOpen ? "active" : "inactive"; return
-
{ alertCount={props.alertCount} helpState={props.helpState} />
-
+
; }; diff --git a/frontend/nav/ticker_list.tsx b/frontend/nav/ticker_list.tsx index e7c1ec3418..59f41d55a5 100644 --- a/frontend/nav/ticker_list.tsx +++ b/frontend/nav/ticker_list.tsx @@ -11,7 +11,6 @@ import { GetWebAppConfigValue } from "../config_storage/actions"; import { MessageType } from "../sequences/interfaces"; import { t } from "../i18next_wrapper"; import { TimeSettings } from "../interfaces"; -import { forceOnline } from "../devices/must_be_online"; import { formatTime } from "../util"; import { Actions } from "../constants"; @@ -50,9 +49,6 @@ const getFirstTickerLog = ( botOnline: boolean, lastSeen: number, ): TaggedLog => { - if (forceOnline()) { - return demoAccountLog(); - } if (!botOnline) { return generateFallbackLog("bot_offline", t("FarmBot is offline"), lastSeen); } diff --git a/frontend/photos/images/photos.tsx b/frontend/photos/images/photos.tsx index 7e94eeb458..adbe29ef9b 100644 --- a/frontend/photos/images/photos.tsx +++ b/frontend/photos/images/photos.tsx @@ -13,11 +13,10 @@ import { destroy } from "../../api/crud"; import { isNumber, isUndefined, round } from "lodash"; import { isBotOnline } from "../../devices/must_be_online"; import { t } from "../../i18next_wrapper"; -import { Overlay } from "@blueprintjs/core"; import { ImageShowMenu, ImageShowMenuTarget } from "./image_show_menu"; import { setShownMapImages } from "./actions"; import { TaggedImage } from "farmbot"; -import { MarkedSlider, Popover } from "../../ui"; +import { MarkedSlider, Popover, Overlay } from "../../ui"; import { botPositionLabel, } from "../../farm_designer/map/layers/farmbot/bot_position_label"; diff --git a/frontend/point_groups/__tests__/other_sort_methods_test.ts b/frontend/point_groups/__tests__/other_sort_methods_test.ts new file mode 100644 index 0000000000..fca8e208c5 --- /dev/null +++ b/frontend/point_groups/__tests__/other_sort_methods_test.ts @@ -0,0 +1,7 @@ +import { distance } from "../other_sort_methods"; + +describe("distance()", () => { + it("calculates distance", () => { + expect(distance({ x: 0, y: 0 }, { x: 1, y: 0 })).toEqual(1); + }); +}); diff --git a/frontend/point_groups/__tests__/paths_test.tsx b/frontend/point_groups/__tests__/paths_test.tsx index 720f35e729..e7ba9071c6 100644 --- a/frontend/point_groups/__tests__/paths_test.tsx +++ b/frontend/point_groups/__tests__/paths_test.tsx @@ -6,7 +6,7 @@ jest.mock("../../api/crud", () => ({ import React from "react"; import { shallow, mount } from "enzyme"; import { - PathInfoBar, nn, PathInfoBarProps, Paths, PathsProps, + PathInfoBar, PathInfoBarProps, Paths, PathsProps, } from "../paths"; import { fakePointGroup, fakePoint, @@ -15,6 +15,7 @@ import { Actions } from "../../constants"; import { edit } from "../../api/crud"; import { SORT_OPTIONS } from "../point_group_sort"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; +import { nn } from "../other_sort_methods"; /** * p1 -- p2 -- diff --git a/frontend/point_groups/criteria/component.tsx b/frontend/point_groups/criteria/component.tsx index 30f52b38cb..2668227bd1 100644 --- a/frontend/point_groups/criteria/component.tsx +++ b/frontend/point_groups/criteria/component.tsx @@ -20,9 +20,9 @@ import { ToolTips } from "../../constants"; import { overwriteGroup } from "../actions"; import { PointGroupItem } from "../point_group_item"; import { TaggedPoint } from "farmbot"; -import { sortGroup } from "../../farm_designer/map/group_order_visual"; import { equals } from "../../util"; import { floor, take } from "lodash"; +import { sortGroupBy } from "../point_group_sort"; const CRITERIA_POINT_TYPE_LOOKUP = (): Record => ({ @@ -165,7 +165,7 @@ export class GroupPointCountBreakdown }); get sortedGroup() { - return sortGroup( + return sortGroupBy( this.props.tryGroupSortType || this.props.group.body.sort_type, this.props.pointsSelectedByGroup); } diff --git a/frontend/point_groups/other_sort_methods.ts b/frontend/point_groups/other_sort_methods.ts new file mode 100644 index 0000000000..3d5c49eb30 --- /dev/null +++ b/frontend/point_groups/other_sort_methods.ts @@ -0,0 +1,51 @@ +import { isUndefined, sortBy, uniq } from "lodash"; +import { TaggedPoint, TaggedSensorReading } from "farmbot"; + +export const distance = ( + p1: { x: number, y: number }, + p2: { x: number, y: number }, +) => + Math.pow(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2), 0.5); + +export const findNearest = ( + from: { x: number, y: number }, + available: (TaggedPoint | TaggedSensorReading)[], +): TaggedPoint | TaggedSensorReading | undefined => { + const distances = available + .filter(p => !isUndefined(p.body.x) && !isUndefined(p.body.y)) + .map(p => ({ + point: p, + distance: distance({ x: p.body.x as number, y: p.body.y as number }, from) + })); + return sortBy(distances, "distance")[0]?.point; +}; + +export const nn = (pathPoints: TaggedPoint[]) => { + let available = pathPoints.slice(0); + const ordered: (TaggedPoint | TaggedSensorReading)[] = []; + let from = { x: 0, y: 0 }; + pathPoints.map(() => { + const nearest = findNearest(from, available); + if (!nearest || isUndefined(nearest.body.x) || isUndefined(nearest.body.y)) { + return; + } + ordered.push(nearest); + from = { x: nearest.body.x, y: nearest.body.y }; + available = available.filter(p => p.uuid !== nearest.uuid); + }); + return ordered; +}; + +export const alternating = (pathPoints: TaggedPoint[], axis: "xy" | "yx") => { + const axis0: "x" | "y" = axis[0] as "x" | "y"; + const axis1: "x" | "y" = axis[1] as "x" | "y"; + const ordered: TaggedPoint[] = []; + const rowCoordinates = sortBy(uniq(pathPoints.map(p => p.body[axis0]))); + const rows = rowCoordinates.map((rowCoordinate, index) => { + const row = sortBy(pathPoints.filter(p => + p.body[axis0] == rowCoordinate), "body." + axis1); + return index % 2 == 0 ? row : row.reverse(); + }); + rows.map(row => row.map(p => ordered.push(p))); + return ordered; +}; diff --git a/frontend/point_groups/paths.tsx b/frontend/point_groups/paths.tsx index c8a61ee108..7efedbf896 100644 --- a/frontend/point_groups/paths.tsx +++ b/frontend/point_groups/paths.tsx @@ -1,10 +1,11 @@ import React from "react"; import { sortGroupBy, sortOptionsTable } from "./point_group_sort"; -import { isUndefined, sortBy, uniq } from "lodash"; +import { isUndefined } from "lodash"; import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { Actions } from "../constants"; import { edit, save } from "../api/crud"; import { TaggedPointGroup, TaggedPoint, TaggedSensorReading } from "farmbot"; +import { alternating, distance, nn } from "./other_sort_methods"; export const convertToXY = (points: (TaggedPoint | TaggedSensorReading)[]): { x: number, y: number }[] => @@ -13,12 +14,6 @@ export const convertToXY = .filter(p => !isUndefined(p.x) && !isUndefined(p.y)) .map(p => p as { x: number, y: number }); -export const distance = ( - p1: { x: number, y: number }, - p2: { x: number, y: number }, -) => - Math.pow(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2), 0.5); - const pathDistance = (pathPoints: (TaggedPoint | TaggedSensorReading)[]) => { let total = 0; let prev: { x: number, y: number } | undefined = undefined; @@ -30,49 +25,6 @@ const pathDistance = (pathPoints: (TaggedPoint | TaggedSensorReading)[]) => { return Math.round(total); }; -export const findNearest = ( - from: { x: number, y: number }, - available: (TaggedPoint | TaggedSensorReading)[], -): TaggedPoint | TaggedSensorReading | undefined => { - const distances = available - .filter(p => !isUndefined(p.body.x) && !isUndefined(p.body.y)) - .map(p => ({ - point: p, - distance: distance({ x: p.body.x as number, y: p.body.y as number }, from) - })); - return sortBy(distances, "distance")[0]?.point; -}; - -export const nn = (pathPoints: TaggedPoint[]) => { - let available = pathPoints.slice(0); - const ordered: (TaggedPoint | TaggedSensorReading)[] = []; - let from = { x: 0, y: 0 }; - pathPoints.map(() => { - const nearest = findNearest(from, available); - if (!nearest || isUndefined(nearest.body.x) || isUndefined(nearest.body.y)) { - return; - } - ordered.push(nearest); - from = { x: nearest.body.x, y: nearest.body.y }; - available = available.filter(p => p.uuid !== nearest.uuid); - }); - return ordered; -}; - -export const alternating = (pathPoints: TaggedPoint[], axis: "xy" | "yx") => { - const axis0: "x" | "y" = axis[0] as "x" | "y"; - const axis1: "x" | "y" = axis[1] as "x" | "y"; - const ordered: TaggedPoint[] = []; - const rowCoordinates = sortBy(uniq(pathPoints.map(p => p.body[axis0]))); - const rows = rowCoordinates.map((rowCoordinate, index) => { - const row = sortBy(pathPoints.filter(p => - p.body[axis0] == rowCoordinate), "body." + axis1); - return index % 2 == 0 ? row : row.reverse(); - }); - rows.map(row => row.map(p => ordered.push(p))); - return ordered; -}; - export type ExtendedPointGroupSortType = PointGroupSortType; const SORT_TYPES: ExtendedPointGroupSortType[] = [ diff --git a/frontend/point_groups/point_group_sort.ts b/frontend/point_groups/point_group_sort.ts index aff78bba6d..5910156f85 100644 --- a/frontend/point_groups/point_group_sort.ts +++ b/frontend/point_groups/point_group_sort.ts @@ -2,7 +2,7 @@ import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { t } from "../i18next_wrapper"; import { shuffle, sortBy } from "lodash"; import { TaggedPoint } from "farmbot"; -import { alternating, nn } from "./paths"; +import { alternating, nn } from "./other_sort_methods"; export const sortOptionsTable = (): Record => ({ random: t("Random Order"), diff --git a/frontend/points/point_inventory_item.tsx b/frontend/points/point_inventory_item.tsx index 19ad2726fc..3a86925681 100644 --- a/frontend/points/point_inventory_item.tsx +++ b/frontend/points/point_inventory_item.tsx @@ -77,8 +77,8 @@ export const PointInventoryItem = (props: PointInventoryItemProps) => {

{colorOverride - ? `(${point.x}, ${point.y}) z${point.z}` - : `(${point.x}, ${point.y}) r${point.radius}`} + ? `(${round(point.x)}, ${round(point.y)}) z${round(point.z)}` + : `(${round(point.x)}, ${round(point.y)}) r${round(point.radius)}`} {!isUndefined(props.distance) && {` ${round(props.distance)}mm ${t("away")}`}}

diff --git a/frontend/promo/tools.ts b/frontend/promo/tools.ts index ec350a09e3..f7ccd57560 100644 --- a/frontend/promo/tools.ts +++ b/frontend/promo/tools.ts @@ -2,7 +2,7 @@ import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources"; import { ToolName } from "../farm_designer/map/tool_graphics/all_tools"; import { Config } from "../three_d_garden/config"; import { ThreeDTool } from "../three_d_garden/bot/components"; -import { zZero } from "../three_d_garden/helpers"; +import { zDir, zZero } from "../three_d_garden/helpers"; export const PROMO_TOOLS = (config: Config): ThreeDTool[] => { @@ -11,7 +11,7 @@ export const PROMO_TOOLS = (config: Config): ThreeDTool[] => { const promoToolOffset = { x: 110 + config.bedWallThickness - config.bedXOffset, y: config.bedWidthOuter / 2 - config.bedYOffset, - z: zZero(config) - 60, + z: zDir(config) * (zZero(config) - 60), }; return [ @@ -53,7 +53,7 @@ export const PROMO_TOOLS = (config: Config): ThreeDTool[] => { { x: config.x - config.bedXOffset + 140, y: -config.bedYOffset + 15, - z: zZero(config) - 100, + z: zDir(config) * (zZero(config) - 100), toolName: ToolName.seedTrough, toolPulloutDirection: ToolPulloutDirection.NONE, firstTrough: true, diff --git a/frontend/reducer.ts b/frontend/reducer.ts index 2da1c134c0..8d6149b06d 100644 --- a/frontend/reducer.ts +++ b/frontend/reducer.ts @@ -29,7 +29,6 @@ export interface AppState { jobs: JobsAndLogsState; controls: ControlsState; popups: PopupsState; - hotkeyGuide: boolean; } export const emptyState = (): AppState => { @@ -104,7 +103,6 @@ export const emptyState = (): AppState => { jobs: false, connectivity: false, }, - hotkeyGuide: false, }; }; @@ -206,10 +204,6 @@ export const appReducer = s.popups.connectivity = false; return s; }) - .add(Actions.TOGGLE_HOTKEY_GUIDE, (s) => { - s.hotkeyGuide = !s.hotkeyGuide; - return s; - }) .add(Actions.CREATE_TOAST, (s, { payload }) => { s.toasts = { ...s.toasts, [payload.id]: payload }; return s; diff --git a/frontend/resources/__tests__/selectors_by_id_test.ts b/frontend/resources/__tests__/selectors_by_id_test.ts index f00c31db48..2d2b4f1376 100644 --- a/frontend/resources/__tests__/selectors_by_id_test.ts +++ b/frontend/resources/__tests__/selectors_by_id_test.ts @@ -2,6 +2,7 @@ import { maybeFindGenericPointerById, maybeFindPeripheralById, maybeFindPlantTemplateById, + maybeFindPointById, maybeFindSavedGardenById, maybeFindSensorById, maybeFindSequenceById, @@ -9,6 +10,13 @@ import { } from "../selectors_by_id"; import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; +describe("maybeFindPointById()", () => { + it("handles missing uuid", () => { + expect(maybeFindPointById(buildResourceIndex([]).index, 1)) + .toEqual(undefined); + }); +}); + describe("maybeFindPlantTemplateById()", () => { it("handles missing uuid", () => { expect(maybeFindPlantTemplateById(buildResourceIndex([]).index, 1)) diff --git a/frontend/resources/__tests__/selectors_test.ts b/frontend/resources/__tests__/selectors_test.ts index 5b5a2521b5..ab4d8faf1e 100644 --- a/frontend/resources/__tests__/selectors_test.ts +++ b/frontend/resources/__tests__/selectors_test.ts @@ -17,6 +17,7 @@ const SLOT_ID = 100; const fakeTool: TaggedTool = arrayUnwrap(newTaggedResource("Tool", { name: "yadda yadda", flow_rate_ml_per_s: 0, + seeder_tip_z_offset: 80, id: TOOL_ID })); const fakeSlot: TaggedToolSlotPointer = arrayUnwrap(newTaggedResource("Point", @@ -54,6 +55,33 @@ describe("findSlotByToolId", () => { }); }); +describe("maybeFindSlotByToolId", () => { + it("returns undefined when tool not found", () => { + const state = resourceReducer(buildResourceIndex(), saveOK(fakeTool)); + expect(state.index.byKindAndId["Tool." + fakeTool.body.id]) + .toEqual(fakeTool.uuid); + const result = Selector.maybeFindSlotByToolId(state.index, 0); + expect(result).toBeFalsy(); + }); + + it("returns undefined when slot not found", () => { + const state = resourceReducer(buildResourceIndex(), saveOK(fakeTool)); + expect(state.index.byKindAndId["Tool." + fakeTool.body.id]) + .toEqual(fakeTool.uuid); + const result = Selector.maybeFindSlotByToolId(state.index, TOOL_ID); + expect(result).toBeFalsy(); + }); + + it("returns something when there is a match", () => { + const initialState = buildResourceIndex(); + const state = [saveOK(fakeTool), saveOK(fakeSlot)] + .reduce(resourceReducer, initialState); + const result = Selector.maybeFindSlotByToolId(state.index, TOOL_ID); + expect(result).toBeTruthy(); + if (result) { expect(result.kind).toBe("Point"); } + }); +}); + describe("getFeeds", () => { it("returns empty array", () => { expect(Selector.selectAllWebcamFeeds(emptyState().index).length).toBe(0); diff --git a/frontend/resources/selectors_by_id.ts b/frontend/resources/selectors_by_id.ts index f1ea071420..02cf8b63bf 100644 --- a/frontend/resources/selectors_by_id.ts +++ b/frontend/resources/selectors_by_id.ts @@ -105,6 +105,32 @@ export const findSlotByToolId = (index: ResourceIndex, tool_id: number) => { } }; +/** Maybe find a Tool's corresponding Slot. */ +export const maybeFindSlotByToolId = ( + index: ResourceIndex, + tool_id: number, +) => { + const tool = maybeFindToolById(index, tool_id); + if (!tool) { return undefined; } + const query = { body: { tool_id: tool.body.id } }; + const every = Object + .keys(index.references) + .map(x => index.references[x]); + const tts = find(every, query); + if (tts && !isNumber(tts) && isTaggedToolSlotPointer(tts)) { + return tts; + } else { + return undefined; + } +}; + +/** Unlike other findById methods, this one allows undefined (missed) values */ +export function maybeFindPointById(index: ResourceIndex, id: number) { + const uuid = index.byKindAndId[joinKindAndId("Point", id)]; + const resource = index.references[uuid || "nope"]; + if (resource?.kind === "Point") { return resource; } +} + /** Unlike other findById methods, this one allows undefined (missed) values */ export function maybeFindPlantById(index: ResourceIndex, id: number) { const uuid = index.byKindAndId[joinKindAndId("Point", id)]; diff --git a/frontend/routes.tsx b/frontend/routes.tsx index 89050d8146..e1905a524a 100644 --- a/frontend/routes.tsx +++ b/frontend/routes.tsx @@ -8,7 +8,7 @@ import { ErrorBoundary } from "./error_boundary"; import { Route, BrowserRouter, Routes } from "react-router"; import { ROUTE_DATA } from "./route_config"; import { Provider } from "react-redux"; -import { HotkeysProvider } from "@blueprintjs/core"; +import { BlueprintProvider } from "@blueprintjs/core"; import { Provider as RollbarProvider } from "@rollbar/react"; import { NavigationProvider } from "./routes_helpers"; import { App } from "./app"; @@ -55,7 +55,7 @@ export class RootComponent return - + @@ -78,7 +78,7 @@ export class RootComponent - + ; diff --git a/frontend/sequences/__tests__/all_steps_test.tsx b/frontend/sequences/__tests__/all_steps_test.tsx index 4696d73119..bdf60a539c 100644 --- a/frontend/sequences/__tests__/all_steps_test.tsx +++ b/frontend/sequences/__tests__/all_steps_test.tsx @@ -62,7 +62,7 @@ describe("", () => { it("displays hover highlight", () => { const p = fakeProps(); - p.visualized = true; + p.visualized = "uuid"; p.sequence.body.body = [{ kind: "wait", args: { milliseconds: 0 } }]; p.sequence.body.body.map(step => maybeTagStep(step)); p.hoveredStep = getStepTag(p.sequence.body.body[0]); @@ -72,7 +72,7 @@ describe("", () => { it("doesn't display hover highlight", () => { const p = fakeProps(); - p.visualized = false; + p.visualized = undefined; p.sequence.body.body = [{ kind: "wait", args: { milliseconds: 0 } }]; p.sequence.body.body.map(step => maybeTagStep(step)); p.hoveredStep = getStepTag(p.sequence.body.body[0]); diff --git a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx index ba2a2b629f..290b79a14d 100644 --- a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx +++ b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx @@ -28,7 +28,6 @@ const mockCB = jest.fn(); jest.mock("../locals_list/locals_list", () => ({ LocalsList: () =>
, localListCallback: jest.fn(() => jest.fn(() => mockCB)), - isParameterDeclaration: jest.fn(), removeVariable: jest.fn(), generateNewVariableLabel: jest.fn(), })); @@ -66,6 +65,7 @@ import { ImportedBanner, AddCommandButtonProps, } from "../sequence_editor_middle_active"; +import { render } from "@testing-library/react"; import { mount, shallow } from "enzyme"; import { ActiveMiddleProps, SequenceBtnGroupProps, SequenceSettingProps, @@ -121,6 +121,7 @@ describe("", () => { getWebAppConfigValue: jest.fn(), sequencesState: emptyState().consumers.sequences, showName: true, + visualized: undefined, }; }; @@ -314,7 +315,7 @@ describe("", () => { it("un-visualizes", () => { location.pathname = Path.mock(Path.designerSequences("1")); const p = fakeProps(); - p.visualized = true; + p.visualized = "uuid"; const wrapper = mount(); wrapper.find(".fa-eye").simulate("click"); expect(p.dispatch).toHaveBeenCalledWith({ @@ -323,12 +324,22 @@ describe("", () => { }); }); + it("re-visualizes", () => { + const p = fakeProps(); + p.visualized = "not uuid"; + render(); + expect(p.dispatch).toHaveBeenCalledWith({ + type: Actions.VISUALIZE_SEQUENCE, + payload: p.sequence.uuid, + }); + }); + it("pins sequence", () => { location.pathname = Path.mock(Path.sequences("1")); const p = fakeProps(); p.sequence.body.pinned = false; const wrapper = mount(); - wrapper.find(".fa-thumb-tack").simulate("click"); + wrapper.find(".fa-bookmark-o").simulate("click"); expect(pinSequenceToggle).toHaveBeenCalledWith(p.sequence); }); @@ -337,7 +348,7 @@ describe("", () => { const p = fakeProps(); p.sequence.body.pinned = true; const wrapper = mount(); - wrapper.find(".fa-thumb-tack").simulate("click"); + wrapper.find(".fa-bookmark").simulate("click"); expect(pinSequenceToggle).toHaveBeenCalledWith(p.sequence); }); @@ -412,7 +423,7 @@ describe("", () => { view: "public", sequencePreview: previewSequence, viewSequenceCeleryScript: true, }); - expect(wrapper.find(".fa-code").hasClass("inactive")).toBeFalsy(); + expect(wrapper.find(".fa-code").hasClass("active")).toBeTruthy(); expect(wrapper.text()).toContain("upgrade"); }); @@ -427,7 +438,7 @@ describe("", () => { view: "public", sequencePreview: previewSequence, viewSequenceCeleryScript: false, }); - expect(wrapper.find(".fa-code").hasClass("inactive")).toBeTruthy(); + expect(wrapper.find(".fa-code").hasClass("active")).toBeFalsy(); }); it("makes selections", () => { @@ -588,6 +599,7 @@ describe("", () => { toggleViewSequenceCeleryScript: jest.fn(), sequencesState: emptyState().consumers.sequences, viewCeleryScript: true, + visualized: undefined, }); it("edits color", () => { @@ -606,7 +618,7 @@ describe("", () => { p.getWebAppConfigValue = () => true; p.viewCeleryScript = true; const wrapper = shallow(); - expect(wrapper.find(".fa-code").hasClass("inactive")).toBeFalsy(); + expect(wrapper.find(".fa-code").hasClass("active")).toBeTruthy(); }); it("shows publish menu", () => { diff --git a/frontend/sequences/__tests__/sequence_editor_middle_test.tsx b/frontend/sequences/__tests__/sequence_editor_middle_test.tsx index 26ff988f2a..42a2139f80 100644 --- a/frontend/sequences/__tests__/sequence_editor_middle_test.tsx +++ b/frontend/sequences/__tests__/sequence_editor_middle_test.tsx @@ -21,6 +21,7 @@ describe("", () => { farmwareData: fakeFarmwareData(), getWebAppConfigValue: jest.fn(), sequencesState: emptyState().consumers.sequences, + visualized: undefined, }; } diff --git a/frontend/sequences/__tests__/sequences_test.tsx b/frontend/sequences/__tests__/sequences_test.tsx index 17aecbc332..9a776fa54c 100644 --- a/frontend/sequences/__tests__/sequences_test.tsx +++ b/frontend/sequences/__tests__/sequences_test.tsx @@ -45,6 +45,7 @@ describe("", () => { sequencesState: emptyState().consumers.sequences, folderData: mapStateToFolderProps(fakeState()), sequencesPanelState: sequencesPanelState(), + visualized: undefined, }); it("renders", () => { diff --git a/frontend/sequences/all_steps.tsx b/frontend/sequences/all_steps.tsx index 180874fd61..af0a7fdf23 100644 --- a/frontend/sequences/all_steps.tsx +++ b/frontend/sequences/all_steps.tsx @@ -22,7 +22,7 @@ export interface AllStepsProps { farmwareData?: FarmwareData; showPins?: boolean; expandStepOptions?: boolean; - visualized?: boolean; + visualized?: string | undefined; hoveredStep?: string | undefined; sequencesState: SequenceReducerState; } diff --git a/frontend/sequences/interfaces.ts b/frontend/sequences/interfaces.ts index 6d65176efc..d934c0353b 100644 --- a/frontend/sequences/interfaces.ts +++ b/frontend/sequences/interfaces.ts @@ -42,7 +42,7 @@ interface SequencePropsBase { resources: ResourceIndex; sequencesState: SequenceReducerState; getWebAppConfigValue: GetWebAppConfigValue; - visualized?: boolean; + visualized: string | undefined; } export interface SequencesProps extends SequencePropsBase { diff --git a/frontend/sequences/locals_list/locals_list.tsx b/frontend/sequences/locals_list/locals_list.tsx index 1ea6d1f9ce..479c011292 100644 --- a/frontend/sequences/locals_list/locals_list.tsx +++ b/frontend/sequences/locals_list/locals_list.tsx @@ -1,7 +1,9 @@ import React from "react"; import { t } from "../../i18next_wrapper"; import { addOrEditDeclarationLocals } from "../locals_list/handle_select"; -import { LocalsListProps, VariableNode } from "../locals_list/locals_list_support"; +import { + isParameterDeclaration, LocalsListProps, VariableNode, +} from "../locals_list/locals_list_support"; import { defensiveClone, betterCompact } from "../../util/util"; import { TaggedSequence, @@ -77,10 +79,6 @@ export const removeVariable = } }; -export const isParameterDeclaration = - (x: VariableNode): x is ParameterDeclaration => - x.kind === "parameter_declaration"; - /** * List of local variables for a sequence. * If none are found, shows nothing. diff --git a/frontend/sequences/locals_list/locals_list_support.ts b/frontend/sequences/locals_list/locals_list_support.ts index 3bf4aa604a..270c12b9bb 100644 --- a/frontend/sequences/locals_list/locals_list_support.ts +++ b/frontend/sequences/locals_list/locals_list_support.ts @@ -72,3 +72,7 @@ export enum VariableType { Text = "Text", Resource = "Resource", } + +export const isParameterDeclaration = + (x: VariableNode): x is ParameterDeclaration => + x.kind === "parameter_declaration"; diff --git a/frontend/sequences/locals_list/variable_form.tsx b/frontend/sequences/locals_list/variable_form.tsx index f7abab5f91..c8a580d637 100644 --- a/frontend/sequences/locals_list/variable_form.tsx +++ b/frontend/sequences/locals_list/variable_form.tsx @@ -126,6 +126,7 @@ export const VariableForm = list.unshift(LOCATION_PLACEHOLDER_DDI()); } const narrowLabel = !!removeVariable; + const [isCustom, setIsCustom] = React.useState(false); return
@@ -155,6 +156,8 @@ export const VariableForm = ? LOCATION_PLACEHOLDER_DDI().label : NO_VALUE_SELECTED_DDI().label} onChange={ddi => { + setIsCustom( + [t("Custom number"), t("Custom text")].includes(ddi.label)); onChange(convertDDItoVariable({ identifierLabel: label, allowedVariableNodes, @@ -177,7 +180,8 @@ export const VariableForm = {!isDefaultValueForm && variableType == VariableType.Number && celeryNode.kind != "parameter_declaration" && - !usingDefaultValue && celeryNode.args.data_value.kind != "identifier" && + (!usingDefaultValue || isCustom) && + celeryNode.args.data_value.kind != "identifier" &&
} {!isDefaultValueForm && variableType == VariableType.Text && celeryNode.kind != "parameter_declaration" && - !usingDefaultValue && celeryNode.args.data_value.kind != "identifier" && + (!usingDefaultValue || isCustom) && + celeryNode.args.data_value.kind != "identifier" &&
({ @@ -22,7 +21,10 @@ const createParameterApplication = const onlyParameterDeclarations = (variableData: VariableNameSet | undefined) => betterCompact(Object.values(variableData || {}) - .map(v => v && isParameterDeclaration(v.celeryNode) ? v.celeryNode : undefined)); + .map(v => + v && isParameterDeclaration(v.celeryNode) + ? v.celeryNode + : undefined)); /** * Create default parameter applications for unassigned variables. diff --git a/frontend/sequences/panel/__tests__/editor_test.tsx b/frontend/sequences/panel/__tests__/editor_test.tsx index e44034dfbc..0c8bc680ea 100644 --- a/frontend/sequences/panel/__tests__/editor_test.tsx +++ b/frontend/sequences/panel/__tests__/editor_test.tsx @@ -70,6 +70,7 @@ describe("", () => { sequencesState: emptyState().consumers.sequences, folderData: mapStateToFolderProps(fakeState()), sequencesPanelState: sequencesPanelState(), + visualized: undefined, }); it("renders", () => { diff --git a/frontend/sequences/panel/__tests__/list_test.tsx b/frontend/sequences/panel/__tests__/list_test.tsx index 0fef3620bf..f218c650f0 100644 --- a/frontend/sequences/panel/__tests__/list_test.tsx +++ b/frontend/sequences/panel/__tests__/list_test.tsx @@ -72,6 +72,7 @@ describe("", () => { sequencesState: emptyState().consumers.sequences, folderData: mapStateToFolderProps(fakeState()), sequencesPanelState: sequencesPanelState(), + visualized: undefined, }); it("renders", () => { diff --git a/frontend/sequences/panel/editor.tsx b/frontend/sequences/panel/editor.tsx index ca5dcdafb2..6becc01cbc 100644 --- a/frontend/sequences/panel/editor.tsx +++ b/frontend/sequences/panel/editor.tsx @@ -78,6 +78,7 @@ export class RawDesignerSequenceEditor dispatch={this.props.dispatch} sequence={sequence} isProcessing={this.isProcessing} + inDesigner={true} setTitleProcessing={processingTitle => this.setState({ processingTitle })} setColorProcessing={processingColor => @@ -169,18 +170,20 @@ interface AutoGenerateButtonProps { dispatch: Function; sequence: TaggedSequence; isProcessing: boolean; + inDesigner: boolean; setTitleProcessing(state: boolean): void; setColorProcessing(state: boolean): void; } export const AutoGenerateButton = (props: AutoGenerateButtonProps) => { const { - dispatch, sequence, isProcessing, setTitleProcessing, setColorProcessing, + dispatch, sequence, isProcessing, inDesigner, setTitleProcessing, setColorProcessing, } = props; return { diff --git a/frontend/sequences/panel/preview_support.tsx b/frontend/sequences/panel/preview_support.tsx index 8b64876bbf..cdcf213c13 100644 --- a/frontend/sequences/panel/preview_support.tsx +++ b/frontend/sequences/panel/preview_support.tsx @@ -179,7 +179,7 @@ const PreviewToolbar = (props: PreviewToolbarProps) => diff --git a/frontend/sequences/sequence_editor_middle.tsx b/frontend/sequences/sequence_editor_middle.tsx index 670a52d2ac..1dae377108 100644 --- a/frontend/sequences/sequence_editor_middle.tsx +++ b/frontend/sequences/sequence_editor_middle.tsx @@ -27,6 +27,7 @@ export class SequenceEditorMiddle hardwareFlags={this.props.hardwareFlags} farmwareData={this.props.farmwareData} getWebAppConfigValue={this.props.getWebAppConfigValue} + visualized={undefined} sequencesState={this.props.sequencesState} />} ; } diff --git a/frontend/sequences/sequence_editor_middle_active.tsx b/frontend/sequences/sequence_editor_middle_active.tsx index 77e87663d6..8f9f762afd 100644 --- a/frontend/sequences/sequence_editor_middle_active.tsx +++ b/frontend/sequences/sequence_editor_middle_active.tsx @@ -265,11 +265,14 @@ export const SequenceBtnGroup = ({ viewCeleryScript, visualized, }: SequenceBtnGroupProps) => { + if (visualized && sequence.uuid != visualized) { + dispatch(visualizeInMap(sequence.uuid)); + } const [processingTitle, setProcessingTitle] = React.useState(false); const [processingColor, setProcessingColor] = React.useState(false); const isProcessing = processingColor || processingTitle; const navigate = useNavigate(); - return
+ return
} /> + {getWebAppConfigValue(BooleanSetting.view_celery_script) && } +
+ } + content={isSequencePublished(sequence) + ? + : } /> +
dispatch(pinSequenceToggle(sequence))} /> + dispatch(copySequence(navigate, sequence))} /> {Path.inDesigner() && dispatch(visualizeInMap(visualized ? undefined : sequence.uuid))} />} - dispatch(copySequence(navigate, sequence))} /> - -
- } - content={isSequencePublished(sequence) - ? - : } /> -
{!Path.inDesigner() && }
-
- - dispatch(save(sequence.uuid)).then(() => - navigate(Path.sequences(urlFriendly(sequence.body.name))))} /> -
+ + dispatch(save(sequence.uuid)).then(() => + navigate(Path.sequences(urlFriendly(sequence.body.name))))} />
; }; @@ -542,12 +542,13 @@ export class SequenceEditorMiddleActive extends showName={this.props.showName} />} {view == "local" ?
- + {!viewSequenceCeleryScript && + } {!viewSequenceCeleryScript && { {props.viewCeleryScript && }
diff --git a/frontend/sequences/state_to_props.ts b/frontend/sequences/state_to_props.ts index d807dc13ec..e83f6548b3 100644 --- a/frontend/sequences/state_to_props.ts +++ b/frontend/sequences/state_to_props.ts @@ -59,7 +59,7 @@ export function mapStateToProps(props: Everything): SequencesProps { getWebAppConfigValue: getConfig, sequencesState: props.resources.consumers.sequences, folderData: mapStateToFolderProps(props), - visualized: !!props.resources.consumers.farm_designer.visualizedSequence, + visualized: props.resources.consumers.farm_designer.visualizedSequence, hoveredStep: props.resources.consumers.farm_designer.hoveredSequenceStep, sequencesPanelState: props.app.sequencesPanelState, }; diff --git a/frontend/sequences/step_button_cluster.tsx b/frontend/sequences/step_button_cluster.tsx index ffe447b0e6..8ad835f1bb 100644 --- a/frontend/sequences/step_button_cluster.tsx +++ b/frontend/sequences/step_button_cluster.tsx @@ -240,7 +240,7 @@ export class StepButtonCluster }); return
", () => { expect(labels.at(1).text()).toEqual("Mode"); expect(buttons.at(0).text()).toEqual("Pin 3"); expect(labels.at(2).text()).toEqual("set to"); - const sliderLabels = wrapper.find(".bp5-slider-label"); + const sliderLabels = wrapper.find(".bp6-slider-label"); [0, 255, 2].map((value, index) => expect(sliderLabels.at(index).text()).toEqual("" + value)); }); diff --git a/frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx b/frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx new file mode 100644 index 0000000000..5fd856ef20 --- /dev/null +++ b/frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx @@ -0,0 +1,128 @@ +let mockDev = false; +jest.mock("../../../../settings/dev/dev_support", () => ({ + DevSettings: { allOrderOptionsEnabled: () => mockDev }, +})); + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { + axisOrder, AxisOrderInputRow, getAxisGroupingState, getAxisRouteState, +} from "../axis_order"; +import { AxisGrouping, AxisOrderInputRowProps, AxisRoute } from "../interfaces"; +import { Move } from "farmbot"; + +describe("", () => { + beforeEach(() => { + mockDev = false; + }); + + const fakeProps = (): AxisOrderInputRowProps => ({ + grouping: undefined, + route: undefined, + safeZ: false, + onChange: jest.fn(), + }); + + it.each<[boolean, AxisGrouping, AxisRoute, string]>([ + [false, "x,y,z", "high", "One at a time"], + [false, "xy,z", "high", "X and Y together"], + [false, "xyz", "high", "All at once"], + [false, undefined, undefined, "Use default"], + [false, "x", "low", "x;low"], + [true, "x", "low", "Safe Z"], + ])("renders order: safe_z=%s %s %s", (safeZ, grouping, route, label) => { + const p = fakeProps(); + p.grouping = grouping; + p.route = route; + p.safeZ = safeZ; + render(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it("changes item", () => { + const p = fakeProps(); + render(); + const dropdown = screen.getByRole("button"); + fireEvent.click(dropdown); + const item = screen.getByRole("menuitem", { name: "X and Y together" }); + fireEvent.click(item); + expect(p.onChange).toHaveBeenCalledWith({ + label: "X and Y together", + value: "xy,z;high", + }); + }); + + it("shows default", () => { + const p = fakeProps(); + p.defaultValue = "safe_z"; + render(); + const dropdown = screen.getByRole("button"); + fireEvent.click(dropdown); + expect(screen.getByRole("menuitem", { name: "Use default (Safe Z)" })) + .toBeInTheDocument(); + }); + + it("shows all order options", () => { + mockDev = true; + const p = fakeProps(); + render(); + const dropdown = screen.getByRole("button"); + fireEvent.click(dropdown); + expect(screen.getByRole("menuitem", { name: "x,yz;high" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Use default" })) + .toBeInTheDocument(); + }); +}); + +describe("axisOrder()", () => { + it("returns node list", () => { + expect(axisOrder(undefined, undefined)).toEqual([]); + expect(axisOrder("xyz", "in_order")).toEqual([ + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, + ]); + }); +}); + +describe("getAxisGroupingState()", () => { + it("returns state: axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [ + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, + ], + }; + expect(getAxisGroupingState(move)).toEqual("z,y,x"); + }); + + it("returns state: no axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [], + }; + expect(getAxisGroupingState(move)).toEqual(undefined); + }); +}); + +describe("getAxisRouteState()", () => { + it("returns state: axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [ + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, + ], + }; + expect(getAxisRouteState(move)).toEqual("in_order"); + }); + + it("returns state: no axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [], + }; + expect(getAxisRouteState(move)).toEqual(undefined); + }); +}); diff --git a/frontend/sequences/step_tiles/tile_computed_move/__tests__/component_test.tsx b/frontend/sequences/step_tiles/tile_computed_move/__tests__/component_test.tsx index 11856ecad0..3df19e6564 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/__tests__/component_test.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/__tests__/component_test.tsx @@ -2,6 +2,7 @@ const mockEditStep = jest.fn(); jest.mock("../../../../api/crud", () => ({ editStep: mockEditStep })); import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { mount, shallow } from "enzyme"; import { ComputedMove } from "../component"; import { Move, SpecialValue } from "farmbot"; @@ -16,6 +17,10 @@ import { } from "../test_fixtures"; import { inputEvent } from "../../../../__test_support__/fake_html_events"; import { StepParams } from "../../../interfaces"; +import { + buildResourceIndex, +} from "../../../../__test_support__/resource_index_builder"; +import { fakeFbosConfig } from "../../../../__test_support__/fake_state/resources"; describe("", () => { const fakeProps = (): StepParams => { @@ -114,7 +119,7 @@ describe("", () => { }); it("shows options", () => { - const MORE = ["offset", "variance", "safe"]; + const MORE = ["offset", "variance", "order"]; const wrapper = mount(); MORE.map(string => expect(wrapper.text().toLowerCase()).not.toContain(string)); @@ -123,6 +128,19 @@ describe("", () => { expect(wrapper.text().toLowerCase()).toContain(string)); }); + it("shows options: axis order", () => { + const MORE = ["order"]; + const p = fakeProps(); + p.currentStep = { + kind: "move", args: {}, body: [{ + kind: "axis_order", args: { grouping: "xyz", route: "high" } + }], + }; + const wrapper = mount(); + MORE.map(string => + expect(wrapper.text().toLowerCase()).toContain(string)); + }); + it("enables additional option display", () => { const wrapper = shallow(); expect(wrapper.state().more).toEqual(false); @@ -132,9 +150,34 @@ describe("", () => { it("enables safe z", () => { const wrapper = shallow(); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); expect(wrapper.state().safeZ).toEqual(false); - wrapper.instance().toggleSafeZ(); + wrapper.instance().setAxisOrder({ label: "", value: "safe_z" }); expect(wrapper.state().safeZ).toEqual(true); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); + }); + + it("enables axis order", () => { + const wrapper = shallow(); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); + expect(wrapper.state().safeZ).toEqual(false); + wrapper.instance().setAxisOrder({ label: "", value: "xyz;high" }); + expect(wrapper.state().safeZ).toEqual(false); + expect(wrapper.state().axisGrouping).toEqual("xyz"); + expect(wrapper.state().axisRoute).toEqual("high"); + }); + + it("handles config", () => { + const p = fakeProps(); + const config = fakeFbosConfig(); + config.body.default_axis_order = "safe_z"; + p.resources = buildResourceIndex([config]).index; + render(); + fireEvent.click(screen.getByText("[]")); + expect(screen.getByText("Use default (Safe Z)")).toBeInTheDocument(); }); it("commits number value", () => { diff --git a/frontend/sequences/step_tiles/tile_computed_move/__tests__/safe_z_test.tsx b/frontend/sequences/step_tiles/tile_computed_move/__tests__/safe_z_test.tsx index af1e78bfdb..cf0269af20 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/__tests__/safe_z_test.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/__tests__/safe_z_test.tsx @@ -1,16 +1,7 @@ -import React from "react"; -import { mount } from "enzyme"; -import { SafeZCheckboxProps } from "../interfaces"; -import { SafeZCheckbox } from "../safe_z"; +import { SAFE_Z } from "../safe_z"; -describe("", () => { - const fakeProps = (): SafeZCheckboxProps => ({ - checked: false, - onChange: jest.fn(), - }); - - it("renders", () => { - const wrapper = mount(); - expect(wrapper.text().toLowerCase()).toContain("safe z"); +describe("SAFE_Z", () => { + it("returns node", () => { + expect(SAFE_Z).toEqual({ kind: "safe_z", args: {} }); }); }); diff --git a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx new file mode 100644 index 0000000000..6e58b20bac --- /dev/null +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { Row, Help, FBSelect, DropDownItem } from "../../../ui"; +import { t } from "../../../i18next_wrapper"; +import { ToolTips } from "../../../constants"; +import { Move, AxisOrder } from "farmbot"; +import { + AxisGrouping, AxisOrderInputRowProps, AxisRoute, +} from "./interfaces"; +import { DevSettings } from "../../../settings/dev/dev_support"; + +export const axisOrder = ( + grouping: AxisGrouping, + route: AxisRoute, +): AxisOrder[] => + !(grouping && route) ? [] : [{ kind: "axis_order", args: { grouping, route } }]; + +export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => { + const defaultLabel = props.defaultValue + ? ` (${DDI_LOOKUP()[props.defaultValue].label})` + : ""; + return +
+ + +
+ +
; +}; + +export const getAxisOrderOptions = () => + DevSettings.allOrderOptionsEnabled() ? ALL_DDIS() : DDIS(); + +export const getSelectedAxisOrder = ( + safeZ: boolean, + grouping: AxisGrouping, + route: AxisRoute, +) => { + if (safeZ) { return DDI_LOOKUP().safe_z; } + if (grouping && route) { return DDI_LOOKUP()[ddiValue(grouping, route)]; } + return undefined; +}; + +const ddiValue = (grouping: AxisGrouping, route: AxisRoute): string => + [grouping, route].join(";"); + +const DDIS = (): DropDownItem[] => [ + DDI_LOOKUP().safe_z, + DDI_LOOKUP()[ddiValue("x,y,z", "high")], + DDI_LOOKUP()[ddiValue("xy,z", "high")], + DDI_LOOKUP()[ddiValue("xyz", "high")], +]; + +const getLabel = (value: string): string => { + switch (value) { + case ddiValue("x,y,z", "high"): + return t("One at a time"); + case ddiValue("xy,z", "high"): + return t("X and Y together"); + case ddiValue("xyz", "high"): + return t("All at once"); + case "safe_z": + return t("Safe Z"); + default: + return value; + } +}; + +const GROUPINGS: AxisGrouping[] = [ + "x", + "x,y", + "x,y,z", + "x,yz", + "x,z", + "x,z,y", + "xy", + "xy,z", + "xyz", + "xz", + "xz,y", + "y", + "y,x", + "y,x,z", + "y,xz", + "y,z", + "y,z,x", + "yz", + "yz,x", + "z", + "z,x", + "z,x,y", + "z,xy", + "z,y", + "z,y,x", +]; + +const ROUTES: AxisRoute[] = [ + "high", + "low", + "in_order", +]; + +const getAllDdiValues = (): string[] => { + const ddiValues: string[] = ["safe_z"]; + GROUPINGS.map(grouping => + ROUTES.map(route => { + ddiValues.push(ddiValue(grouping, route)); + })); + return ddiValues; +}; + +const DDI_LOOKUP = (): Record => { + return getAllDdiValues() + .reduce( + (acc, value) => { + acc[value] = { label: getLabel(value), value }; + return acc; + }, + {} as Record); +}; + +const ALL_DDIS = (): DropDownItem[] => + getAllDdiValues().map(value => DDI_LOOKUP()[value]); + +export const getAxisGroupingState = (step: Move) => { + const axisOrder = step.body?.find(x => x.kind == "axis_order"); + if (axisOrder?.kind == "axis_order") { + return axisOrder.args.grouping; + } +}; + +export const getAxisRouteState = (step: Move) => { + const axisOrder = step.body?.find(x => x.kind == "axis_order"); + if (axisOrder?.kind == "axis_order") { + return axisOrder.args.route; + } +}; + +export const getNewAxisOrderState = (ddi: DropDownItem) => { + const safeZ = ddi.value == "safe_z"; + const [grouping, route] = ("" + ddi.value).split(";"); + return { + axisGrouping: safeZ ? undefined : grouping as AxisGrouping, + axisRoute: safeZ ? undefined : route as AxisRoute, + safeZ, + }; +}; diff --git a/frontend/sequences/step_tiles/tile_computed_move/component.tsx b/frontend/sequences/step_tiles/tile_computed_move/component.tsx index f9d26f2c9e..85912630c4 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/component.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/component.tsx @@ -1,6 +1,6 @@ import React from "react"; import { StepWrapper } from "../../step_ui"; -import { Row, ExpandableHeader } from "../../../ui"; +import { Row, ExpandableHeader, DropDownItem } from "../../../ui"; import { ToolTips } from "../../../constants"; import { t } from "../../../i18next_wrapper"; import { Move, Xyz } from "farmbot"; @@ -27,8 +27,13 @@ import { import { getSpeedState, getSpeedNode, speedOverwrite, SpeedInputRow, } from "./speed"; -import { SafeZCheckbox, getSafeZState, SAFE_Z } from "./safe_z"; +import { getSafeZState, SAFE_Z } from "./safe_z"; +import { + axisOrder, AxisOrderInputRow, getAxisGroupingState, getAxisRouteState, + getNewAxisOrderState, +} from "./axis_order"; import { StepParams } from "../../interfaces"; +import { getFbosConfig } from "../../../resources/getters"; /** * _Computed move_ @@ -98,6 +103,8 @@ export class ComputedMove z: getSpeedState(this.step, "z"), }, safeZ: getSafeZState(this.step), + axisGrouping: getAxisGroupingState(this.step), + axisRoute: getAxisRouteState(this.step), }; get step() { return this.props.currentStep; } @@ -178,6 +185,7 @@ export class ComputedMove ...speedOverwrite("y", this.speedNodes.y), ...speedOverwrite("z", this.speedNodes.z), ...(this.state.safeZ ? [SAFE_Z] : []), + ...axisOrder(this.state.axisGrouping, this.state.axisRoute), ]; }; @@ -241,7 +249,9 @@ export class ComputedMove } }, this.update); - toggleSafeZ = () => this.setState({ safeZ: !this.state.safeZ }, this.update); + setAxisOrder = (ddi: DropDownItem) => { + this.setState({ ...this.state, ...getNewAxisOrderState(ddi) }, this.update); + }; toggleMore = () => this.setState({ more: !this.state.more }); LocationInputRow = () => @@ -304,11 +314,20 @@ export class ComputedMove setAxisState={this.setAxisState} /> : undefined; - SafeZCheckbox = () => - (this.state.safeZ || this.state.more) - ? + AxisOrderInputRow = () => { + const defaultAxisOrder = + getFbosConfig(this.props.resources)?.body.default_axis_order; + return ((this.state.axisGrouping && this.state.axisRoute) + || this.state.safeZ + || this.state.more) + ? : undefined; + }; render() { return - + ; } } diff --git a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts index 3bb0c9cc3b..ddd5c104c8 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts +++ b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts @@ -1,11 +1,17 @@ import { Identifier, Point, Tool, TaggedSequence, Move, Xyz, AxisOverwrite, + ALLOWED_GROUPING, + ALLOWED_ROUTE, } from "farmbot"; import { ResourceIndex, UUID } from "../../../resources/interfaces"; import { BotPosition } from "../../../devices/interfaces"; +import { DropDownItem } from "../../../ui"; export type LocationNode = Identifier | Point | Tool; +export type AxisGrouping = ALLOWED_GROUPING | undefined; +export type AxisRoute = ALLOWED_ROUTE | undefined; + export interface ComputedMoveState { locationSelection: LocSelection | undefined; location: LocationNode | undefined; @@ -16,6 +22,8 @@ export interface ComputedMoveState { variance: Record; speed: Record; safeZ: boolean; + axisGrouping: AxisGrouping; + axisRoute: AxisRoute; viewRaw?: boolean; } @@ -68,11 +76,6 @@ export interface LocationSelectionProps { sequenceUuid: UUID; } -export interface SafeZCheckboxProps { - checked: boolean; - onChange(): void; -} - interface InputRowBase { disabledAxes: Record; onCommit: CommitMoveField; @@ -96,6 +99,14 @@ export interface SpeedInputRowProps extends InputRowBase { setAxisState: SetAxisState; } +export interface AxisOrderInputRowProps { + onChange(ddi: DropDownItem): void; + grouping: AxisGrouping; + route: AxisRoute; + safeZ: boolean; + defaultValue?: string; +} + export interface OverwriteInputRowProps extends InputRowBase { selection: Record; overwrite: Record; diff --git a/frontend/sequences/step_tiles/tile_computed_move/safe_z.tsx b/frontend/sequences/step_tiles/tile_computed_move/safe_z.tsx index ffb0a45efc..bb5a38c046 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/safe_z.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/safe_z.tsx @@ -1,21 +1,4 @@ -import React from "react"; -import { Row, Help } from "../../../ui"; -import { t } from "../../../i18next_wrapper"; -import { ToolTips } from "../../../constants"; -import { Checkbox } from "@blueprintjs/core"; import { Move, SafeZ } from "farmbot"; -import { SafeZCheckboxProps } from "./interfaces"; - -export const SafeZCheckbox = (props: SafeZCheckboxProps) => - -
- - -
- -
; export const getSafeZState = (step: Move) => { const safeZ = step.body?.find(x => x.kind == "safe_z"); diff --git a/frontend/sequences/step_tiles/tile_computed_move/test_fixtures.ts b/frontend/sequences/step_tiles/tile_computed_move/test_fixtures.ts index 9017abff7e..b515219f77 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/test_fixtures.ts +++ b/frontend/sequences/step_tiles/tile_computed_move/test_fixtures.ts @@ -15,6 +15,8 @@ export const fakeNumericMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: 10, y: 10, z: 10 }, safeZ: true, + axisGrouping: undefined, + axisRoute: undefined, }); export const fakeNumericMoveStepCeleryScript: Move = { @@ -144,6 +146,8 @@ export const fakeLuaMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: "10", y: "10", z: "10" }, safeZ: true, + axisGrouping: undefined, + axisRoute: undefined, }); export const fakeLuaMoveStepCeleryScript: Move = { diff --git a/frontend/sequences/step_tiles/tile_execute.tsx b/frontend/sequences/step_tiles/tile_execute.tsx index 3a0cd30000..ea251b6d54 100644 --- a/frontend/sequences/step_tiles/tile_execute.tsx +++ b/frontend/sequences/step_tiles/tile_execute.tsx @@ -7,11 +7,13 @@ import { ToolTips } from "../../constants"; import { StepWrapper } from "../step_ui"; import { SequenceSelectBox } from "../sequence_select_box"; import { findSequenceById } from "../../resources/selectors_by_id"; -import { isParameterDeclaration, LocalsList } from "../locals_list/locals_list"; +import { LocalsList } from "../locals_list/locals_list"; import { addOrEditParamApps, variableList, } from "../locals_list/variable_support"; -import { AllowedVariableNodes } from "../locals_list/locals_list_support"; +import { + AllowedVariableNodes, isParameterDeclaration, +} from "../locals_list/locals_list_support"; import { isNumber } from "lodash"; /** Replaces the execute step body with a new array of variables. */ diff --git a/frontend/sequences/step_ui/__tests__/step_icon_group_test.tsx b/frontend/sequences/step_ui/__tests__/step_icon_group_test.tsx index 1822beb90c..2252c61c7f 100644 --- a/frontend/sequences/step_ui/__tests__/step_icon_group_test.tsx +++ b/frontend/sequences/step_ui/__tests__/step_icon_group_test.tsx @@ -42,7 +42,7 @@ describe("", () => { [StateToggleKey.monacoEditor]: { enabled: true, toggle: () => false } }; const wrapper = mount(); - expect(wrapper.find(".fa-font").hasClass("enabled")).toEqual(true); + expect(wrapper.find(".fa-font").hasClass("active")).toEqual(false); }); it("renders monaco editor disabled", () => { @@ -51,7 +51,7 @@ describe("", () => { [StateToggleKey.monacoEditor]: { enabled: false, toggle: () => true } }; const wrapper = mount(); - expect(wrapper.find(".fa-font").hasClass("enabled")).toEqual(false); + expect(wrapper.find(".fa-font").hasClass("active")).toEqual(true); }); it("renders expanded editor enabled", () => { @@ -79,7 +79,7 @@ describe("", () => { p.viewRaw = true; p.toggleViewRaw = () => false; const wrapper = mount(); - expect(wrapper.find(".fa-code").hasClass("enabled")).toEqual(true); + expect(wrapper.find(".fa-code").hasClass("active")).toEqual(true); }); it("renders prompt", () => { @@ -96,7 +96,7 @@ describe("", () => { p.viewRaw = false; p.toggleViewRaw = () => true; const wrapper = mount(); - expect(wrapper.find(".fa-code").hasClass("enabled")).toEqual(false); + expect(wrapper.find(".fa-code").hasClass("active")).toEqual(false); }); it("deletes step", () => { diff --git a/frontend/sequences/step_ui/step_icon_group.tsx b/frontend/sequences/step_ui/step_icon_group.tsx index 1bcc22d561..487b2b75de 100644 --- a/frontend/sequences/step_ui/step_icon_group.tsx +++ b/frontend/sequences/step_ui/step_icon_group.tsx @@ -74,7 +74,7 @@ export function StepIconGroup(props: StepIconBarProps) { title={t("open linked sequence")} onClick={onSequenceLinkNav(props.executeSequenceName)} />} {monaco && - } {expanded && @@ -86,7 +86,7 @@ export function StepIconGroup(props: StepIconBarProps) { ].join(" ")} onClick={expanded.toggle} />} {props.toggleViewRaw && - } ", () => { getConfigValue: () => 0, firmwareConfig: undefined, sourceFwConfig: () => ({ value: 10, consistent: true }), - sourceFbosConfig: () => ({ value: 10, consistent: true }), + sourceFbosConfig: x => ({ + value: fakeFbosConfig().body[x as keyof FbosConfig], consistent: true, + }), resources: buildResourceIndex([]).index, deviceAccount: fakeDevice(), alerts: [], diff --git a/frontend/settings/dev/__tests__/dev_settings_test.tsx b/frontend/settings/dev/__tests__/dev_settings_test.tsx index f067edeca6..0f0234a1b6 100644 --- a/frontend/settings/dev/__tests__/dev_settings_test.tsx +++ b/frontend/settings/dev/__tests__/dev_settings_test.tsx @@ -5,11 +5,14 @@ jest.mock("../../../config_storage/actions", () => ({ })); import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; import { mount, shallow } from "enzyme"; import { DevWidgetFERow, DevWidgetFBOSRow, DevWidgetDelModeRow, DevWidgetShowInternalEnvsRow, DevWidget3dCameraRow, + DevWidgetAllOrderOptionsRow, + DevWidgetChunkingDisabledRow, } from "../dev_settings"; import { DevSettings } from "../dev_support"; import { setWebAppConfigValue } from "../../../config_storage/actions"; @@ -122,3 +125,38 @@ describe("", () => { delete mockDevSettings[DevSettings.SHOW_INTERNAL_ENVS]; }); }); + +describe("", () => { + it("enables all order options", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(setWebAppConfigValue).toHaveBeenCalledWith("internal_use", + JSON.stringify({ [DevSettings.ALL_ORDER_OPTIONS]: "true" })); + delete mockDevSettings[DevSettings.ALL_ORDER_OPTIONS]; + }); + + it("disables all order options", () => { + mockDevSettings[DevSettings.ALL_ORDER_OPTIONS] = "true"; + render(); + fireEvent.click(screen.getByRole("button")); + expect(setWebAppConfigValue).toHaveBeenCalledWith("internal_use", "{}"); + delete mockDevSettings[DevSettings.ALL_ORDER_OPTIONS]; + }); +}); + +describe("", () => { + it("enables chunking disabled", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(localStorage.getItem("DISABLE_CHUNKING")).toEqual("true"); + localStorage.removeItem("DISABLE_CHUNKING"); + }); + + it("disables chunking disabled", () => { + localStorage.setItem("DISABLE_CHUNKING", "true"); + render(); + fireEvent.click(screen.getByRole("button")); + expect(localStorage.getItem("DISABLE_CHUNKING")).toBeFalsy(); + localStorage.removeItem("DISABLE_CHUNKING"); + }); +}); diff --git a/frontend/settings/dev/dev_settings.tsx b/frontend/settings/dev/dev_settings.tsx index c4a545d2e0..472da3655e 100644 --- a/frontend/settings/dev/dev_settings.tsx +++ b/frontend/settings/dev/dev_settings.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Row, BlurableInput, ToggleButton } from "../../ui"; import { DevSettings } from "./dev_support"; +import { store } from "../../redux/store"; export const DevWidgetFERow = () => @@ -77,6 +78,30 @@ export const DevWidgetShowInternalEnvsRow = () => : DevSettings.enableShowInternalEnvs} /> ; +export const DevWidgetAllOrderOptionsRow = () => + + + + ; + +export const DevWidgetChunkingDisabledRow = () => + + + localStorage.removeItem("DISABLE_CHUNKING") + : () => localStorage.setItem("DISABLE_CHUNKING", "true")} /> + ; + export const DevSettingsRows = () =>
@@ -84,4 +109,7 @@ export const DevSettingsRows = () => + + +

Demo Queue Length: {store.getState().bot.demoQueueLength}

; diff --git a/frontend/settings/dev/dev_support.ts b/frontend/settings/dev/dev_support.ts index 8268e1b2d8..b8c6740e77 100644 --- a/frontend/settings/dev/dev_support.ts +++ b/frontend/settings/dev/dev_support.ts @@ -13,6 +13,7 @@ namespace devStorage { QUICK_DELETE_MODE = "QUICK_DELETE_MODE", SHOW_INTERNAL_ENVS = "SHOW_INTERNAL_ENVS", CAMERA3D = "CAMERA3D", + ALL_ORDER_OPTIONS = "ALL_ORDER_OPTIONS", } type Storage = { [K in Key]: string }; @@ -88,4 +89,12 @@ export namespace DevSettings { export const set3dCamera = (details: string) => devStorage.setItem(CAMERA3D, details); export const remove3dCamera = () => devStorage.removeItem(CAMERA3D); + + export const ALL_ORDER_OPTIONS = devStorage.Key.ALL_ORDER_OPTIONS; + export const allOrderOptionsEnabled = () => + !!devStorage.getItem(ALL_ORDER_OPTIONS); + export const enableAllOrderOptions = () => + devStorage.setItem(ALL_ORDER_OPTIONS, "true"); + export const disableAllOrderOptions = () => + devStorage.removeItem(ALL_ORDER_OPTIONS); } diff --git a/frontend/settings/fbos_settings/__tests__/default_axis_order_test.tsx b/frontend/settings/fbos_settings/__tests__/default_axis_order_test.tsx new file mode 100644 index 0000000000..10a0e1aa1b --- /dev/null +++ b/frontend/settings/fbos_settings/__tests__/default_axis_order_test.tsx @@ -0,0 +1,26 @@ +jest.mock("../../../devices/actions", () => ({ + updateConfig: jest.fn(), +})); + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { DefaultAxisOrder } from "../default_axis_order"; +import { DefaultAxisOrderProps } from "../interfaces"; +import { updateConfig } from "../../../devices/actions"; + +describe("", () => { + const fakeProps = (): DefaultAxisOrderProps => ({ + sourceFbosConfig: () => ({ value: "safe_z", consistent: true }), + dispatch: jest.fn(), + }); + + it("renders", () => { + render(); + expect(screen.getByText("Safe Z")).toBeInTheDocument(); + const dropdown = screen.getByRole("button"); + fireEvent.click(dropdown); + const item = screen.getByRole("menuitem", { name: "X and Y together" }); + fireEvent.click(item); + expect(updateConfig).toHaveBeenCalledWith({ default_axis_order: "xy,z;high" }); + }); +}); diff --git a/frontend/settings/fbos_settings/default_axis_order.tsx b/frontend/settings/fbos_settings/default_axis_order.tsx new file mode 100644 index 0000000000..add638621a --- /dev/null +++ b/frontend/settings/fbos_settings/default_axis_order.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { t } from "../../i18next_wrapper"; +import { Row, Help, FBSelect } from "../../ui"; +import { DeviceSetting, ToolTips } from "../../constants"; +import { Highlight } from "../maybe_highlight"; +import { DefaultAxisOrderProps } from "./interfaces"; +import { getModifiedClassName } from "./default_values"; +import { + getAxisOrderOptions, + getSelectedAxisOrder, +} from "../../sequences/step_tiles/tile_computed_move/axis_order"; +import { + AxisGrouping, AxisRoute, +} from "../../sequences/step_tiles/tile_computed_move/interfaces"; +import { updateConfig } from "../../devices/actions"; + +export const DefaultAxisOrder = (props: DefaultAxisOrderProps) => { + const value = props.sourceFbosConfig("default_axis_order").value as string; + const modified = getModifiedClassName("default_axis_order", value, undefined); + const safeZ = value === "safe_z"; + const [grouping, route] = value.split(";") as [AxisGrouping, AxisRoute]; + return + +
+ + +
+ { + props.dispatch(updateConfig({ default_axis_order: "" + ddi.value })); + }} /> +
+
; +}; diff --git a/frontend/settings/fbos_settings/default_values.ts b/frontend/settings/fbos_settings/default_values.ts index b7befdec60..507068291d 100644 --- a/frontend/settings/fbos_settings/default_values.ts +++ b/frontend/settings/fbos_settings/default_values.ts @@ -7,7 +7,9 @@ import { ConfigurationName, FirmwareHardware } from "farmbot"; import { getModifiedClassNameSpecifyModified } from "../default_values"; import { cloneDeep } from "lodash"; -type Key = BooleanFbosConfigKey | StringFbosConfigKey | NumberFbosConfigKey +type Key = BooleanFbosConfigKey + | StringFbosConfigKey + | NumberFbosConfigKey | ConfigurationName; type Value = string | number | boolean | undefined; const DEFAULT_FBOS_CONFIG_VALUES: Record = { @@ -29,6 +31,7 @@ const DEFAULT_FBOS_CONFIG_VALUES: Record = { safe_height: 0, soil_height: 0, gantry_height: 120, + default_axis_order: "xy,z;high", }; const DEFAULT_EXPRESS_FBOS_CONFIG_VALUES = diff --git a/frontend/settings/fbos_settings/interfaces.ts b/frontend/settings/fbos_settings/interfaces.ts index 04bb09e3f7..a4c943a3c3 100644 --- a/frontend/settings/fbos_settings/interfaces.ts +++ b/frontend/settings/fbos_settings/interfaces.ts @@ -102,3 +102,8 @@ export interface ZHeightInputProps { dispatch: Function; sourceFbosConfig: SourceFbosConfig; } + +export interface DefaultAxisOrderProps { + dispatch: Function; + sourceFbosConfig: SourceFbosConfig; +} diff --git a/frontend/settings/firmware/firmware_hardware_support.ts b/frontend/settings/firmware/firmware_hardware_support.ts index 6b2a21f6bd..92305c271b 100644 --- a/frontend/settings/firmware/firmware_hardware_support.ts +++ b/frontend/settings/firmware/firmware_hardware_support.ts @@ -207,7 +207,7 @@ export const FIRMWARE_CHOICES_DDI = { }; export const getFirmwareChoices = () => ([ - ...(shouldDisplayFeature(Feature.farmduino_k18) ? [FARMDUINO_K18] : []), + FARMDUINO_K18, FARMDUINO_K17, FARMDUINO_K16, FARMDUINO_K15, diff --git a/frontend/settings/hardware_settings/__tests__/axis_settings_test.tsx b/frontend/settings/hardware_settings/__tests__/axis_settings_test.tsx index 4ffd7cc7d7..90a63c5ca2 100644 --- a/frontend/settings/hardware_settings/__tests__/axis_settings_test.tsx +++ b/frontend/settings/hardware_settings/__tests__/axis_settings_test.tsx @@ -16,6 +16,7 @@ import { mount, shallow } from "enzyme"; import { AxisSettings } from "../axis_settings"; import { bot } from "../../../__test_support__/fake_state/bot"; import { + fakeFbosConfig, fakeFirmwareConfig, } from "../../../__test_support__/fake_state/resources"; import { error, warning } from "../../../toast/toast"; @@ -29,6 +30,7 @@ import { buildResourceIndex, } from "../../../__test_support__/resource_index_builder"; import { edit, save } from "../../../api/crud"; +import { FbosConfig } from "farmbot/dist/resources/configs/fbos"; describe("", () => { const state = fakeState(); @@ -43,7 +45,7 @@ describe("", () => { value: bot.hardware.mcu_params[x], consistent: true }), sourceFbosConfig: x => ({ - value: bot.hardware.configuration[x], consistent: true + value: fakeFbosConfig().body[x as keyof FbosConfig], consistent: true, }), firmwareConfig: fakeConfig.body, botOnline: true, diff --git a/frontend/settings/hardware_settings/__tests__/default_values_test.ts b/frontend/settings/hardware_settings/__tests__/default_values_test.ts index 3b78e8bb3e..643db3e933 100644 --- a/frontend/settings/hardware_settings/__tests__/default_values_test.ts +++ b/frontend/settings/hardware_settings/__tests__/default_values_test.ts @@ -21,5 +21,7 @@ describe("getModifiedClassName()", () => { expect(getModifiedClassName("encoder_enabled_x", 1, "arduino")).toEqual(""); expect(getModifiedClassName("encoder_enabled_x", 0, "arduino")) .toEqual("modified"); + expect(getModifiedClassName("encoder_enabled_x", 0, "arduino", () => 1)) + .toEqual(""); }); }); diff --git a/frontend/settings/hardware_settings/axis_settings.tsx b/frontend/settings/hardware_settings/axis_settings.tsx index 076cf2eda4..2706189475 100644 --- a/frontend/settings/hardware_settings/axis_settings.tsx +++ b/frontend/settings/hardware_settings/axis_settings.tsx @@ -21,6 +21,7 @@ import { } from "../fbos_settings/z_height_inputs"; import { setAxisLength } from "../../controls/move/bot_position_rows"; import { validBotLocationData } from "../../util/location"; +import { DefaultAxisOrder } from "../fbos_settings/default_axis_order"; export const AxisSettings = (props: AxisSettingsProps) => { @@ -176,6 +177,9 @@ export const AxisSettings = (props: AxisSettingsProps) => { + ; }; diff --git a/frontend/settings/maybe_highlight.tsx b/frontend/settings/maybe_highlight.tsx index b265d6c079..5ff7dd0cc9 100644 --- a/frontend/settings/maybe_highlight.tsx +++ b/frontend/settings/maybe_highlight.tsx @@ -50,6 +50,7 @@ const AXES_PANEL = [ DeviceSetting.axisLength, DeviceSetting.safeHeight, DeviceSetting.fallbackSoilHeight, + DeviceSetting.defaultAxisOrder, ]; const MOTORS_PANEL = [ DeviceSetting.motors, diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx index d3d5b318e1..f5b160d42f 100644 --- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx +++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx @@ -125,7 +125,7 @@ describe("", () => { it("changes value: radio", () => { const p = fakeProps(); const wrapper = mount(); - wrapper.find({ type: "radio" }).at(2).simulate("change", + wrapper.find({ type: "radio" }).at(7).simulate("change", { target: { value: "Jr" } }); expect(p.setConfig).toHaveBeenCalledWith({ ...p.config, diff --git a/frontend/three_d_garden/__tests__/time_travel_test.tsx b/frontend/three_d_garden/__tests__/time_travel_test.tsx index e99aa60a9b..eb6193ff8e 100644 --- a/frontend/three_d_garden/__tests__/time_travel_test.tsx +++ b/frontend/three_d_garden/__tests__/time_travel_test.tsx @@ -29,15 +29,6 @@ describe("", () => { }; }; - it("renders without lat/lng", () => { - const p = fakeProps(); - p.isOpen = true; - p.device.lat = undefined; - p.device.lng = undefined; - const { container } = render(); - expect(container).not.toContainHTML("time-travel-button"); - }); - it("renders open", () => { const p = fakeProps(); p.isOpen = true; diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts new file mode 100644 index 0000000000..1c3332409e --- /dev/null +++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts @@ -0,0 +1,51 @@ +import { getZFunc, precomputeTriangles } from "../triangle_functions"; + +describe("precomputeTriangles()", () => { + it("computes triangles: zero", () => { + expect(precomputeTriangles([ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], [0, 1, 2])).toEqual([]); + }); + + it("computes triangles", () => { + expect(precomputeTriangles([ + [1, 1, 0], + [4, 1, 0], + [2, 3, 0], + ], [0, 1, 2])).toEqual([{ + a: [1, 1, 0], + b: [4, 1, 0], + c: [2, 3, 0], + det: 6, + x1: 1, + x2: 4, + x3: 2, + y1: 1, + y2: 1, + y3: 3, + }]); + }); +}); + +describe("getZFunc()", () => { + it("gets Z: falls back", () => { + expect(getZFunc([], -100)(0, 0)).toEqual(-100); + }); + + it("gets Z", () => { + expect(getZFunc([{ + a: [0, 0, 10], + b: [2, 0, 20], + c: [0, 2, 30], + det: 4, + x1: 0, + x2: 2, + x3: 0, + y1: 0, + y2: 0, + y3: 2, + }], -100)(1, 1)).toEqual(25); + }); +}); diff --git a/frontend/three_d_garden/__tests__/triangles_test.ts b/frontend/three_d_garden/__tests__/triangles_test.ts index 63ee0c8edd..319eeac646 100644 --- a/frontend/three_d_garden/__tests__/triangles_test.ts +++ b/frontend/three_d_garden/__tests__/triangles_test.ts @@ -1,59 +1,9 @@ -import { computeSurface, getZFunc, precomputeTriangles } from "../triangles"; +import { computeSurface } from "../triangles"; import { INITIAL } from "../config"; import { clone } from "lodash"; import { fakePoint } from "../../__test_support__/fake_state/resources"; import { tagAsSoilHeight } from "../../points/soil_height"; -describe("precomputeTriangles()", () => { - it("computes triangles: zero", () => { - expect(precomputeTriangles([ - [0, 0, 0], - [0, 0, 0], - [0, 0, 0], - ], [0, 1, 2])).toEqual([]); - }); - - it("computes triangles", () => { - expect(precomputeTriangles([ - [1, 1, 0], - [4, 1, 0], - [2, 3, 0], - ], [0, 1, 2])).toEqual([{ - a: [1, 1, 0], - b: [4, 1, 0], - c: [2, 3, 0], - det: 6, - x1: 1, - x2: 4, - x3: 2, - y1: 1, - y2: 1, - y3: 3, - }]); - }); -}); - -describe("getZFunc()", () => { - it("gets Z: falls back", () => { - expect(getZFunc([], -100)(0, 0)).toEqual(-100); - }); - - it("gets Z", () => { - expect(getZFunc([{ - a: [0, 0, 10], - b: [2, 0, 20], - c: [0, 2, 30], - det: 4, - x1: 0, - x2: 2, - x3: 0, - y1: 0, - y2: 0, - y3: 2, - }], -100)(1, 1)).toEqual(25); - }); -}); - const zs = (items: [number, number, number][]) => items.map(i => i[2]); describe("computeSurface()", () => { diff --git a/frontend/three_d_garden/__tests__/visualization_test.tsx b/frontend/three_d_garden/__tests__/visualization_test.tsx new file mode 100644 index 0000000000..842c4d6af3 --- /dev/null +++ b/frontend/three_d_garden/__tests__/visualization_test.tsx @@ -0,0 +1,78 @@ +import { + buildResourceIndex, +} from "../../__test_support__/resource_index_builder"; +let mockResources = buildResourceIndex([]); +jest.mock("../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ resources: mockResources }), + }, +})); + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Visualization, VisualizationProps } from "../visualization"; +import { INITIAL } from "../config"; +import { clone } from "lodash"; +import { + fakeFbosConfig, fakeFirmwareConfig, fakeSequence, + fakeWebAppConfig, +} from "../../__test_support__/fake_state/resources"; +import { findSequence } from "../../resources/selectors_by_kind"; + +describe("", () => { + const fakeProps = (): VisualizationProps => ({ + config: clone(INITIAL), + visualizedSequenceUUID: undefined, + }); + + it("doesn't render: no uuid", () => { + render(); + expect(screen.queryByText("visualization")).toBeNull(); + }); + + it("doesn't render: no sequence id", () => { + const p = fakeProps(); + const sequence = fakeSequence(); + sequence.body.id = undefined; + mockResources = buildResourceIndex([sequence]); + p.visualizedSequenceUUID = + findSequence(mockResources.index, sequence.uuid)?.uuid; + render(); + expect(screen.queryByText("visualization")).toBeNull(); + }); + + it("renders first point", () => { + const p = fakeProps(); + const sequence = fakeSequence(); + sequence.body.id = 1; + mockResources = buildResourceIndex([sequence]); + p.visualizedSequenceUUID = + findSequence(mockResources.index, sequence.uuid)?.uuid; + render(); + expect(screen.getByText("visualization")).toBeInTheDocument(); + }); + + it("renders: with sequence id and points", () => { + const p = fakeProps(); + const sequence = fakeSequence(); + sequence.body.id = 1; + sequence.body.body = [ + { + kind: "move_absolute", + args: { + location: { kind: "coordinate", args: { x: 100, y: 100, z: 0 } }, + offset: { kind: "coordinate", args: { x: 0, y: 0, z: 0 } }, + speed: 100, + }, + }, + ]; + mockResources = buildResourceIndex([ + sequence, fakeFbosConfig(), fakeFirmwareConfig(), fakeWebAppConfig(), + ]); + p.visualizedSequenceUUID = + findSequence(mockResources.index, sequence.uuid)?.uuid; + render(); + expect(screen.getByText("visualization")).toBeInTheDocument(); + }); +}); diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 7e61e9af7f..bf77716842 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -1,5 +1,6 @@ import React from "react"; import { mount } from "enzyme"; +import { render } from "@testing-library/react"; import { Bot, FarmbotModelProps } from "../bot"; import { INITIAL } from "../../config"; import { clone } from "lodash"; @@ -28,7 +29,7 @@ describe("", () => { expect(wrapper.html()).toContain("bot"); expect(wrapper.html()).toContain("water-tube"); expect(wrapper.find({ name: "slot" }).last().props().position) - .toEqual([-1350, 200, 60]); + .toEqual([-1345, 200, 51]); }); it("renders: Jr", () => { @@ -39,7 +40,7 @@ describe("", () => { const wrapper = mount(); expect(wrapper.html()).toContain("bot"); expect(wrapper.find({ name: "slot" }).last().props().position) - .toEqual([-1350, 100, 60]); + .toEqual([-1345, 100, 51]); }); it("renders: v1.7", () => { @@ -56,9 +57,19 @@ describe("", () => { expect(wrapper.find({ name: "button-group" }).length).toEqual(9); // 3 * 3 }); + it("renders watering animation", () => { + const p = fakeProps(); + p.config.waterFlow = true; + jest.useFakeTimers(); + const { container, rerender } = render(); + jest.runAllTimers(); + rerender(); + expect(container).toContainHTML("watering-animations"); + }); + it("loads shapes", () => { const p = fakeProps(); - mount(); + render(); expect(SVGLoader.createShapes).toHaveBeenCalledTimes(15); }); }); diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 6c921eddcc..b70697e3b1 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -25,13 +25,14 @@ import { Group, Mesh, MeshPhongMaterial } from "../components"; import { ElectronicsBox, Bounds, Tools, Solenoid, XAxisWaterTube, CableCarrierX, - CableCarrierVertical, + CableCarrierSupportVertical, CableCarrierZ, CableCarrierY, - CableCarrierHorizontal, + CableCarrierSupportHorizontal, GantryBeam, } from "./components"; import { SlotWithTool } from "../../resources/interfaces"; +import { WateringAnimations } from "./components/watering_animations"; export const extrusionWidth = 20; const utmRadius = 35; @@ -488,7 +489,7 @@ export const Bot = (props: FarmbotModelProps) => { zZero - zDir * z + zAxisLength / 2, ]} rotation={[Math.PI / 2, 0, 0]} /> - + { position={[ threeSpace(x + 23, bedLengthOuter) + bedXOffset, threeSpace(y + 25 + extrusionWidth / 2, bedWidthOuter) + bedYOffset, - zZero - zDir * z - 140 + zGantryOffset, + zZero - zDir * z - 140 + zGantryOffset + 20, ]}> { position={[ threeSpace(x + 11, bedLengthOuter) + bedXOffset, threeSpace(y, bedWidthOuter) + bedYOffset, - zZero - zDir * z + utmHeight / 2 - 18, + zZero - zDir * z + utmHeight / 2 - 19, ]} rotation={[0, 0, Math.PI / 2]} scale={1000}> @@ -604,7 +605,7 @@ export const Bot = (props: FarmbotModelProps) => { config={config} aluminumTexture={aluminumTexture} beamShape={beamShape} /> - + { getZ={props.getZ} toolSlots={props.toolSlots} mountedToolName={props.mountedToolName} /> + {config.waterFlow && + } diff --git a/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx b/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx index 9caf2e1f23..7cae158c00 100644 --- a/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx @@ -1,52 +1,52 @@ import React from "react"; import { render } from "@testing-library/react"; import { - CableCarrierVertical, CableCarrierVerticalProps, - CableCarrierHorizontal, CableCarrierHorizontalProps, + CableCarrierSupportVertical, CableCarrierSupportVerticalProps, + CableCarrierSupportHorizontal, CableCarrierSupportHorizontalProps, } from "../cable_carriers"; import { clone } from "lodash"; import { INITIAL } from "../../../config"; describe("", () => { - const fakeProps = (): CableCarrierVerticalProps => ({ + const fakeProps = (): CableCarrierSupportVerticalProps => ({ config: clone(INITIAL), }); it("renders v1.7", () => { const p = fakeProps(); p.config.kitVersion = "v1.7"; - const wrapper = render(); - expect(wrapper.container).toContainHTML("ccVertical"); + const wrapper = render(); + expect(wrapper.container).toContainHTML("ccSupportVertical"); expect(wrapper.container.querySelectorAll("mesh").length).toBe(4); }); it("renders v1.8", () => { const p = fakeProps(); p.config.kitVersion = "v1.8"; - const wrapper = render(); - expect(wrapper.container).toContainHTML("ccVertical"); + const wrapper = render(); + expect(wrapper.container).toContainHTML("ccSupportVertical"); expect(wrapper.container.querySelectorAll("mesh").length).toBe(1); }); }); describe("", () => { - const fakeProps = (): CableCarrierHorizontalProps => ({ + const fakeProps = (): CableCarrierSupportHorizontalProps => ({ config: clone(INITIAL), }); it("renders v1.7", () => { const p = fakeProps(); p.config.kitVersion = "v1.7"; - const wrapper = render(); - expect(wrapper.container).toContainHTML("ccHorizontal"); + const wrapper = render(); + expect(wrapper.container).toContainHTML("ccSupportHorizontal"); expect(wrapper.container.querySelectorAll("mesh").length).toBe(5); }); it("renders v1.8", () => { const p = fakeProps(); p.config.kitVersion = "v1.8"; - const wrapper = render(); - expect(wrapper.container).toContainHTML("ccHorizontal"); + const wrapper = render(); + expect(wrapper.container).toContainHTML("ccSupportHorizontal"); expect(wrapper.container.querySelectorAll("mesh").length).toBe(1); }); @@ -54,8 +54,8 @@ describe("", () => { const p = fakeProps(); p.config.kitVersion = "v1.8"; p.config.light = true; - const wrapper = render(); - expect(wrapper.container).toContainHTML("ccHorizontal"); + const wrapper = render(); + expect(wrapper.container).toContainHTML("ccSupportHorizontal"); expect(wrapper.container.querySelectorAll("mesh").length).toBe(1); }); }); diff --git a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx index cc84bc791f..d38c3b02ba 100644 --- a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx @@ -116,17 +116,6 @@ describe("", () => { expect(container).not.toContainHTML("toolbay3"); }); - it("renders watering animations when not in toolbay and water flowing", () => { - const p = fakeProps(); - p.config.waterFlow = true; - const tool = fakeTool(); - tool.body.name = "watering nozzle"; - p.toolSlots = []; - p.mountedToolName = "watering nozzle"; - render(); - expect(WateringAnimations).toHaveBeenCalled(); - }); - it("renders vacuum animation when not in toolbay and vacuum", () => { const p = fakeProps(); p.config.vacuum = true; diff --git a/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx b/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx index d65b5611af..7cbb5b25c5 100644 --- a/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/watering_animations_test.tsx @@ -3,17 +3,22 @@ import { render } from "@testing-library/react"; import { WateringAnimations, WateringAnimationsProps, } from "../watering_animations"; +import { clone } from "lodash"; +import { INITIAL } from "../../../config"; describe("", () => { const fakeProps = (): WateringAnimationsProps => ({ waterFlow: true, - botPosition: { x: 0, y: 0, z: 0 }, + config: clone(INITIAL), getZ: () => 0, }); it("renders", () => { + jest.useFakeTimers(); const p = fakeProps(); - const { container } = render(); + const { container, rerender } = render(); + jest.runAllTimers(); + rerender(); const streams = container.querySelectorAll("[name*='water-stream']"); expect(streams.length).toEqual(16); diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index 3c908a9c9d..f56329f5fe 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -15,12 +15,12 @@ import { Group, Mesh, MeshPhongMaterial } from "../../components"; import { distinguishableBlack, extrusionWidth } from "../bot"; import { EMISSIVE_PROPS } from "./gantry_beam"; -type CCHorizontal = GLTF & { - nodes: { [PartName.ccHorizontal]: THREE.Mesh }; +type CCSupportHorizontal = GLTF & { + nodes: { [PartName.ccSupportHorizontal]: THREE.Mesh }; materials: never; } -type CCVertical = GLTF & { - nodes: { [PartName.ccVertical]: THREE.Mesh }; +type CCSupportVertical = GLTF & { + nodes: { [PartName.ccSupportVertical]: THREE.Mesh }; materials: never; } @@ -130,7 +130,7 @@ export const CableCarrierZ = (props: CableCarrierZProps) => { return { ; }; -export interface CableCarrierVerticalProps { +export interface CableCarrierSupportVerticalProps { config: Config; } -export const CableCarrierVertical = (props: CableCarrierVerticalProps) => { - const { - x, y, z, bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, zAxisLength, - kitVersion, - } = props.config; - const zZero = zZeroFunc(props.config); - const zDir = zDirFunc(props.config); - const ccVertical = useGLTF(ASSETS.models.ccVertical, LIB_DIR) as CCVertical; - switch (kitVersion) { - case "v1.7": - return - {range((zAxisLength - 350) / 200).map((i) => ( - { + const { + x, y, z, bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, zAxisLength, + kitVersion, + } = props.config; + const zZero = zZeroFunc(props.config); + const zDir = zDirFunc(props.config); + const ccSupportVertical = + useGLTF(ASSETS.models.ccSupportVertical, LIB_DIR) as CCSupportVertical; + switch (kitVersion) { + case "v1.7": + return + {range((zAxisLength - 350) / 200).map((i) => ( + + + + ))} + ; + case "v1.8": + return + - + rotation={[0, 0, 0]} + geometry={new THREE.ExtrudeGeometry( + (() => { + const shape = new THREE.Shape(); + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(15, 20); + shape.lineTo(20, 1.5); + shape.lineTo(28.5, 1.5); + shape.lineTo(28.5, -61); + shape.lineTo(24, -63); + shape.lineTo(24, -61.5); + shape.lineTo(27, -60); + shape.lineTo(27, 0); + shape.lineTo(0, 0); + return shape; + })(), + { + depth: zAxisLength - 350, + bevelEnabled: false, + }, + )}> + - ))} - ; - case "v1.8": - return - { - const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(15, 20); - shape.lineTo(20, 1.5); - shape.lineTo(28.5, 1.5); - shape.lineTo(28.5, -61); - shape.lineTo(24, -63); - shape.lineTo(24, -61.5); - shape.lineTo(27, -60); - shape.lineTo(27, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: zAxisLength - 350, - bevelEnabled: false, - }, - )}> - - - ; - } -}; + ; + } + }; -export interface CableCarrierHorizontalProps { +export interface CableCarrierSupportHorizontalProps { config: Config; } -export const CableCarrierHorizontal = (props: CableCarrierHorizontalProps) => { - const { - x, bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, botSizeY, - columnLength, kitVersion, - } = props.config; - const ccHorizontal = useGLTF(ASSETS.models.ccHorizontal, LIB_DIR) as CCHorizontal; - switch (kitVersion) { - case "v1.7": - return - {range((botSizeY - 10) / 300).map((i) => ( - { + const { + x, bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, botSizeY, + columnLength, kitVersion, + } = props.config; + const ccSupportHorizontal = + useGLTF(ASSETS.models.ccSupportHorizontal, LIB_DIR) as CCSupportHorizontal; + switch (kitVersion) { + case "v1.7": + return + {range((botSizeY - 10) / 300).map((i) => ( + + + + ))}; + ; + case "v1.8": + return + - - - ))}; - ; - case "v1.8": - return - { - const shape = new THREE.Shape(); + geometry={new THREE.ExtrudeGeometry( + (() => { + const shape = new THREE.Shape(); - shape.moveTo(0, 0); - shape.lineTo(0, 20); - shape.lineTo(-40, 20); - shape.lineTo(-41, 22.5); - shape.lineTo(-42.5, 22.5); - shape.lineTo(-41.5, 18.5); - shape.lineTo(-30, 18.5); - shape.lineTo(-25, 0); - shape.lineTo(0, 0); - return shape; - })(), - { - depth: botSizeY - 30, - bevelEnabled: false, - }, - )}> - - - ; - } -}; + shape.moveTo(0, 0); + shape.lineTo(0, 20); + shape.lineTo(-40, 20); + shape.lineTo(-41, 22.5); + shape.lineTo(-42.5, 22.5); + shape.lineTo(-41.5, 18.5); + shape.lineTo(-30, 18.5); + shape.lineTo(-25, 0); + shape.lineTo(0, 0); + return shape; + })(), + { + depth: botSizeY - 30, + bevelEnabled: false, + }, + )}> + + + ; + } + }; diff --git a/frontend/three_d_garden/bot/components/solenoid.tsx b/frontend/three_d_garden/bot/components/solenoid.tsx index 695fab3b6a..ea93ea1af2 100644 --- a/frontend/three_d_garden/bot/components/solenoid.tsx +++ b/frontend/three_d_garden/bot/components/solenoid.tsx @@ -3,7 +3,7 @@ import * as THREE from "three"; import { Config } from "../../config"; import { Group, Mesh } from "../../components"; import { WaterTube } from "./water_tube"; -import { easyCubicBezierCurve3, threeSpace } from "../../helpers"; +import { easyCubicBezierCurve3, threeSpace, zDir as zDirFunc } from "../../helpers"; import { GLTF } from "three-stdlib"; import { useGLTF } from "@react-three/drei"; import { ASSETS, LIB_DIR, PartName } from "../../constants"; @@ -23,6 +23,7 @@ export const Solenoid = (props: SolenoidProps) => { x, y, z, bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, columnLength, zGantryOffset, } = config; + const zDir = zDirFunc(config); const solenoid = useGLTF(ASSETS.models.solenoid, LIB_DIR) as SolenoidPart; return { [ threeSpace(x + 32.5, bedLengthOuter) + bedXOffset, threeSpace(y - 10, bedWidthOuter) + bedYOffset, - columnLength - z - zGantryOffset + 200, + columnLength - zDir * z - zGantryOffset + 200, ], [0, 0, -50], [0, 0, 50], [ threeSpace(x + 2, bedLengthOuter) + bedXOffset, threeSpace(y + 15, bedWidthOuter) + bedYOffset, - columnLength - z - zGantryOffset + 75, + columnLength - zDir * z - zGantryOffset + 75, ], )} tubularSegments={20} diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 4845a4114f..b49aaf5988 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -21,7 +21,6 @@ import { } from "../../../farm_designer/map/tool_graphics/all_tools"; import { Xyz } from "farmbot"; import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources"; -import { WateringAnimations } from "./watering_animations"; import { useNavigate } from "react-router"; import { Path } from "../../../internal_urls"; import { setPanelOpen } from "../../../farm_designer/panel_header"; @@ -169,9 +168,9 @@ export const Tools = (props: ToolsProps) => { const navigate = useNavigate(); return { if (slotProps.id && !isUndefined(props.dispatch) && @@ -227,13 +226,13 @@ export const Tools = (props: ToolsProps) => { rotaryToolImplementRef.current.rotation.z = time * speed; } }); - + const X = 5.5; switch (toolProps.toolName) { case ToolName.rotaryTool: return { return { scale={1000} geometry={wateringNozzle.nodes[PartName.wateringNozzle].geometry} material={wateringNozzle.materials.PaletteMaterial001} /> - {!inToolbay && props.config.waterFlow && - } ; case ToolName.seedBin: return { return { return { return { return { position={[ position.x - 30, position.y - 15, - position.z, + position.z - 40, ]} rotation={[0, 0, Math.PI / 2]}> {toolProps.firstTrough @@ -374,9 +368,9 @@ export const Tools = (props: ToolsProps) => { return @@ -391,7 +385,7 @@ export const Tools = (props: ToolsProps) => { position={[ threeSpace(105 + bedWallThickness, bedLengthOuter), threeSpace(yPosition + bedWidthOuter / 2, bedWidthOuter), - 60, + 50, ]} rotation={[0, 0, -Math.PI / 2]} scale={1000} diff --git a/frontend/three_d_garden/bot/components/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx index f55db9b02b..ff7cc213f1 100644 --- a/frontend/three_d_garden/bot/components/watering_animations.tsx +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -4,26 +4,41 @@ import { Group } from "../../components"; import { ASSETS } from "../../constants"; import { Cloud, Clouds } from "@react-three/drei"; import { WaterStream } from "./water_stream"; -import { easyCubicBezierCurve3 } from "../../helpers"; -import { BotPosition } from "../../../devices/interfaces"; +import { easyCubicBezierCurve3, threeSpace, zDir, zZero } from "../../helpers"; +import { Config } from "../../config"; +import { utmHeight } from "../bot"; export interface WateringAnimationsProps { waterFlow: boolean; - botPosition: BotPosition; + config: Config; getZ(x: number, y: number): number; } export const WateringAnimations = (props: WateringAnimationsProps) => { - const { waterFlow, botPosition, getZ } = props; - const nozzleToSoil = (botPosition.z || 0) - + getZ(botPosition.x || 0, botPosition.y || 0); - - return + const { waterFlow, getZ, config } = props; + const { x, y, z, bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = config; + const utmZ = -zDir(config) * z + utmHeight / 2 - 15; + const nozzleToSoil = getZ(x, y) - utmZ; + const [visible, setVisible] = React.useState(false); + React.useEffect(() => { + const timer = setTimeout(() => { + setVisible(true); + }, 50); + return () => clearTimeout(timer); + }, []); + return {range(16).map(i => { const angle = (i * Math.PI * 2) / 16; return { { { + + options={["v1.8", "v1.7", "v1000"]} /> { + @@ -359,6 +362,7 @@ export const PrivateOverlay = (props: OverlayProps) => { + { + diff --git a/frontend/three_d_garden/constants.ts b/frontend/three_d_garden/constants.ts index 36d33b74fb..cc895848fb 100644 --- a/frontend/three_d_garden/constants.ts +++ b/frontend/three_d_garden/constants.ts @@ -42,8 +42,8 @@ export const ASSETS: Record> = { beltClip: "/3D/models/belt_clip.glb", zStop: "/3D/models/z_stop.glb", utm: "/3D/models/utm.glb", - ccHorizontal: "/3D/models/cc_horizontal.glb", - ccVertical: "/3D/models/cc_vertical.glb", + ccSupportHorizontal: "/3D/models/cc_support_horizontal.glb", + ccSupportVertical: "/3D/models/cc_support_vertical.glb", housingVertical: "/3D/models/housing_vertical.glb", horizontalMotorHousing: "/3D/models/horizontal_motor_housing.glb", zAxisMotorMount: "/3D/models/z_axis_motor_mount.glb", @@ -121,8 +121,8 @@ export enum PartName { zStop = "Z-Axis_Hardstop", beltClip = "Belt_Clip_-_Slim", utm = "M5_Barb", - ccHorizontal = "60mm_Horizontal_Cable_Carrier_Support", - ccVertical = "60mm_Vertical_Cable_Carrier_Support", + ccSupportHorizontal = "60mm_Horizontal_Cable_Carrier_Support", + ccSupportVertical = "60mm_Vertical_Cable_Carrier_Support", housingVertical = "80mm_Vertical_Motor_Housing", horizontalMotorHousing = "75mm_Horizontal_Motor_Housing", zAxisMotorMount = "Z-Axis_Motor_Mount", @@ -132,8 +132,6 @@ export enum PartName { toolbay1Logo = "mesh0_mesh_1", seeder = "Seeder_Brass_Insert", weeder = "Weeder_Blade_(medium)", - rotaryToolBase = "rotary_tool", - rotaryToolImplement = "rotary_tool001", vacuumPump = "Lower_Vacuum_Tube", wateringNozzle = "M5_x_30mm_Screw", seedBin = "Seed_Bin", diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index c1b37ab97a..1bbc965b9e 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -83,6 +83,7 @@ describe("", () => { .mockImplementationOnce(() => mock4Ref) .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock1Ref) + .mockImplementationOnce(() => mock1Ref) .mockImplementationOnce(() => mock0Ref) .mockImplementationOnce(() => mockMaterialRef); jest.spyOn(React, "useState").mockReturnValue([[], jest.fn()]); diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 442913c4c4..9b358676e5 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -10,14 +10,14 @@ import { BufferAttribute, BufferGeometry, Group, MeshBasicMaterial, PointLight, Points, PointsMaterial, } from "../components"; -import { Line, Sphere, Trail } from "@react-three/drei"; +import { Billboard, Line, Sphere, Text3D, Trail } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; import SunCalc from "suncalc"; import { range } from "lodash"; import moment from "moment"; import { SEASON_DURATIONS } from "../../promo/constants"; import { Line2 } from "three/examples/jsm/lines/Line2"; -import { BigDistance } from "../constants"; +import { ASSETS, BigDistance } from "../constants"; const sunDecay = 0; const shadowNormalBias = 100; @@ -60,19 +60,29 @@ export const calcSunCoordinate = ( }; }; +const toRad = (degrees: number) => degrees * Math.PI / 180; +const polarToCartesian = ( + radius: number, + thetaDegrees: number, + phiDegrees: number, +): [number, number, number] => { + const theta = toRad(thetaDegrees); + const phi = toRad(phiDegrees); + const x = radius * Math.sin(phi) * Math.cos(theta); + const y = radius * Math.sin(phi) * Math.sin(theta); + const z = radius * Math.cos(phi); + return [x, y, z]; +}; + export const sunPosition = ( sunInclination: number, sunAzimuth: number, distance: number, ): Vector3 => { - const toRad = (degrees: number) => degrees * Math.PI / 180; - const azimuth = toRad(sunAzimuth); - const inclination = toRad(sunInclination); - return new Vector3( - distance * Math.cos(inclination) * Math.sin(azimuth), - distance * Math.cos(inclination) * Math.cos(azimuth), - distance * Math.sin(inclination), - ); + const theta = 90 - sunAzimuth; + const phi = 90 - sunInclination; + const position = polarToCartesian(distance, theta, phi); + return new Vector3(...position); }; const convertColor = @@ -132,6 +142,8 @@ export const Sun = (props: SunProps) => { // eslint-disable-next-line no-null/no-null const sunRef = React.useRef(null); // eslint-disable-next-line no-null/no-null + const sunFlatRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null const lineRef = React.useRef(null); const [points, setPoints] = React.useState( range(4).map(index => new Vector3(...offsetSunPos(sunPos, index))), @@ -184,6 +196,8 @@ export const Sun = (props: SunProps) => { const visualPos = sunPosition(inclination, azimuth, BigDistance.sunVisual); sunRef.current?.position?.set(visualPos.x, visualPos.y, visualPos.z); + const flatPos = sunPosition(0, azimuth, BigDistance.ground); + sunFlatRef.current?.position?.set(flatPos.x, flatPos.y, flatPos.z); if (lineRef.current) { // eslint-disable-next-line @react-three/no-new-in-loop @@ -233,21 +247,25 @@ export const Sun = (props: SunProps) => { + {config.lightsDebug && } + {config.lightsDebug && + + } ; }; const generateOtherSuns = () => { const points = []; - const maxPhi = Math.PI / 2 - (10 * Math.PI / 180); + const maxPhi = 80; const r = BigDistance.sunVisual; for (let i = 0; i < 1000; i++) { - const theta = Math.random() * 2 * Math.PI; + const theta = Math.random() * 360; const phi = Math.random() * maxPhi; - - const x = r * Math.sin(phi) * Math.cos(theta); - const y = r * Math.sin(phi) * Math.sin(theta); - const z = r * Math.cos(phi); - points.push(x, y, z); + const position = polarToCartesian(r, theta, phi); + points.push(...position); } return new Float32Array(points); }; @@ -272,3 +290,30 @@ const OtherSuns = ({ starsRef }: { starsRef: React.RefObject }) depthWrite={false} /> ; }; + +interface SkyGridProps { + config: Config; +} + +const SkyGrid = (props: SkyGridProps) => { + const radius = BigDistance.ground; + return + {range(0, 360, 15).map((angle, index) => { + const newAngle = (angle + props.config.heading) % 360; + const [x, y, z] = polarToCartesian(radius, newAngle, 90); + return + + + + {`${360 - angle}°`} + + + + ; + })} + ; +}; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 2534e95482..f576510781 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -30,8 +30,10 @@ import { BooleanSetting } from "../session_keys"; import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; import { isMobile } from "../screen_size"; -import { computeSurface, getZFunc, precomputeTriangles } from "./triangles"; +import { computeSurface } from "./triangles"; import { BigDistance } from "./constants"; +import { precomputeTriangles, getZFunc } from "./triangle_functions"; +import { Visualization } from "./visualization"; const AnimatedGroup = animated(Group); @@ -95,6 +97,9 @@ export const GardenModel = (props: GardenModelProps) => { computeSurface(props.mapPoints, config), [props.mapPoints, config]); const triangles = React.useMemo(() => precomputeTriangles(vertexList, faces), [vertexList, faces]); + React.useEffect(() => { + sessionStorage.setItem("triangles", JSON.stringify(triangles)); + }, [triangles]); const getZ = getZFunc(triangles, -config.soilHeight); // eslint-disable-next-line no-null/no-null @@ -211,6 +216,9 @@ export const GardenModel = (props: GardenModelProps) => { getZ={getZ} dispatch={dispatch} />)} + diff --git a/frontend/three_d_garden/helpers.ts b/frontend/three_d_garden/helpers.ts index 7ef534c7b0..739205f33f 100644 --- a/frontend/three_d_garden/helpers.ts +++ b/frontend/three_d_garden/helpers.ts @@ -35,7 +35,7 @@ export const zero = (config: Config): Record<"x" | "y" | "z", number> => ({ export const extents = (config: Config): Record<"x" | "y" | "z", number> => ({ x: threeSpace(config.bedXOffset + config.botSizeX, config.bedLengthOuter), y: threeSpace(config.bedYOffset + config.botSizeY, config.bedWidthOuter), - z: zZero(config) + zDir(config) * config.botSizeZ, + z: zZero(config) - config.botSizeZ, }); export const easyCubicBezierCurve3 = ( diff --git a/frontend/three_d_garden/time_travel.tsx b/frontend/three_d_garden/time_travel.tsx index 887982694a..3bf0e7fa33 100644 --- a/frontend/three_d_garden/time_travel.tsx +++ b/frontend/three_d_garden/time_travel.tsx @@ -22,6 +22,12 @@ export const get3DTime = (threeDTime: string | undefined) => { const calc3DTime = (threeDTime: string | undefined, offset: number) => get3DTime(threeDTime).add(offset, "hour").format("HH:mm"); +export const showTimeTravelButton = ( + threeDGarden: boolean, + device: DeviceAccountSettings, +) => + threeDGarden && latLng(device).valid; + export interface TimeTravelTargetProps { isOpen: boolean; click(): void; @@ -32,7 +38,6 @@ export interface TimeTravelTargetProps { } export const TimeTravelTarget = (props: TimeTravelTargetProps) => { - if (!props.threeDGarden || !latLng(props.device).valid) { return
; } const { threeDTime } = props.designer; return
{ + const triangles: TriangleData[] = []; + + for (let i = 0; i < faces.length; i += 3) { + const a = vertices[faces[i]]; + const b = vertices[faces[i + 1]]; + const c = vertices[faces[i + 2]]; + + const [x1, y1] = [a[0], a[1]]; + const [x2, y2] = [b[0], b[1]]; + const [x3, y3] = [c[0], c[1]]; + + const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); + if (Math.abs(det) < 1e-10) { continue; } + triangles.push({ a, b, c, x1, y1, x2, y2, x3, y3, det }); + } + + return triangles; +}; + +export const getZFunc = ( + triangles: TriangleData[], + fallback: number, +) => + (x: number, y: number) => { + for (const t of triangles) { + const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t; + const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det; + const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / det; + const l3 = 1 - l1 - l2; + + if (l1 >= 0 && l2 >= 0 && l3 >= 0) { + return l1 * a[2] + l2 * b[2] + l3 * c[2]; + } + } + return fallback; + }; diff --git a/frontend/three_d_garden/triangles.ts b/frontend/three_d_garden/triangles.ts index bc83ca81b8..ada4686f84 100644 --- a/frontend/three_d_garden/triangles.ts +++ b/frontend/three_d_garden/triangles.ts @@ -4,60 +4,6 @@ import { Config } from "./config"; import { soilHeightPoint } from "../points/soil_height"; import { zZero } from "./helpers"; -interface TriangleData { - a: [number, number, number]; - b: [number, number, number]; - c: [number, number, number]; - x1: number; - y1: number; - x2: number; - y2: number; - x3: number; - y3: number; - det: number; -} - -export const precomputeTriangles = ( - vertices: [number, number, number][], - faces: number[], -) => { - const triangles: TriangleData[] = []; - - for (let i = 0; i < faces.length; i += 3) { - const a = vertices[faces[i]]; - const b = vertices[faces[i + 1]]; - const c = vertices[faces[i + 2]]; - - const [x1, y1] = [a[0], a[1]]; - const [x2, y2] = [b[0], b[1]]; - const [x3, y3] = [c[0], c[1]]; - - const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); - if (Math.abs(det) < 1e-10) { continue; } - triangles.push({ a, b, c, x1, y1, x2, y2, x3, y3, det }); - } - - return triangles; -}; - -export const getZFunc = ( - triangles: TriangleData[], - fallback: number, -) => - (x: number, y: number) => { - for (const t of triangles) { - const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t; - const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det; - const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / det; - const l3 = 1 - l1 - l2; - - if (l1 >= 0 && l2 >= 0 && l3 >= 0) { - return l1 * a[2] + l2 * b[2] + l3 * c[2]; - } - } - return fallback; - }; - export const computeSurface = ( mapPoints: TaggedGenericPointer[] | undefined, config: Config, diff --git a/frontend/three_d_garden/visualization.tsx b/frontend/three_d_garden/visualization.tsx new file mode 100644 index 0000000000..8bba29da06 --- /dev/null +++ b/frontend/three_d_garden/visualization.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Line } from "@react-three/drei"; +import { collectDemoSequenceActions } from "../demo/lua_runner"; +import { store } from "../redux/store"; +import { findSequence } from "../resources/selectors_by_kind"; +import { expandActions } from "../demo/lua_runner/actions"; +import { threeSpace, zZero as zZeroFunc } from "./helpers"; +import { Config } from "./config"; + +export interface VisualizationProps { + visualizedSequenceUUID: string | undefined; + config: Config; +} + +export const Visualization = (props: VisualizationProps) => { + const { visualizedSequenceUUID, config } = props; + const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, x, y, z } = config; + const zZero = zZeroFunc(config); + const visualizationPoints = React.useMemo(() => { + if (!visualizedSequenceUUID) { return []; } + const resources = store.getState().resources.index; + const sequence = findSequence(resources, visualizedSequenceUUID); + if (!sequence.body.id) { return []; } + const stashedPos = { x, y, z }; + const actions = + collectDemoSequenceActions(0, resources, sequence.body.id, []); + const points = [[stashedPos.x, stashedPos.y, stashedPos.z]] + .concat(expandActions(actions, [], stashedPos) + .filter(action => action.type == "expanded_move_absolute") + .map(action => action.args as [number, number, number])) + .map(coordinate => [ + threeSpace(coordinate[0], bedLengthOuter) + bedXOffset, + threeSpace(coordinate[1], bedWidthOuter) + bedYOffset, + zZero + coordinate[2], + ] as [number, number, number]); + return points; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visualizedSequenceUUID, + bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, + zZero]); + return visualizationPoints.length > 0 && + ; +}; diff --git a/frontend/toast/constants.ts b/frontend/toast/constants.ts new file mode 100644 index 0000000000..e8a66fac26 --- /dev/null +++ b/frontend/toast/constants.ts @@ -0,0 +1,12 @@ +import { t } from "../i18next_wrapper"; + +export const TOAST_OPTIONS = + (): Record => ({ + success: { title: t("Success"), color: "green" }, + info: { title: t("FYI"), color: "blue" }, + warn: { title: t("Warning"), color: "orange" }, + error: { title: t("Error"), color: "red" }, + busy: { title: t("Busy"), color: "yellow" }, + debug: { title: t("Debug"), color: "gray" }, + fun: { title: t("Did you know?"), color: "dark-blue" }, + }); diff --git a/frontend/toast/toast.ts b/frontend/toast/toast.ts index 4c8b09f428..6a5b09d2cc 100644 --- a/frontend/toast/toast.ts +++ b/frontend/toast/toast.ts @@ -1,8 +1,8 @@ import { createToastOnce } from "./toast_internal_support"; -import { t } from "../i18next_wrapper"; import { ToastOptions } from "./interfaces"; import { Actions } from "../constants"; import { store } from "../redux/store"; +import { TOAST_OPTIONS } from "./constants"; /** * Orange message with "Warning" as the default title. @@ -10,8 +10,7 @@ import { store } from "../redux/store"; export const warning = (message: string, options: ToastOptions = {}) => createToastOnce({ message, - title: t("Warning"), - color: "orange", + ...TOAST_OPTIONS().warn, ...options, fallbackLogger: console.warn, }); @@ -22,8 +21,7 @@ export const warning = (message: string, options: ToastOptions = {}) => export const error = (message: string, options: ToastOptions = {}) => createToastOnce({ message, - title: t("Error"), - color: "red", + ...TOAST_OPTIONS().error, ...options, fallbackLogger: console.error, }); @@ -32,30 +30,25 @@ export const error = (message: string, options: ToastOptions = {}) => * Green message with "Success" as the default title. */ export const success = (message: string, options: ToastOptions = {}) => - createToastOnce({ message, title: t("Success"), color: "green", ...options }); + createToastOnce({ message, ...TOAST_OPTIONS().success, ...options }); /** * Blue message with "FYI" as the default title. */ export const info = (message: string, options: ToastOptions = {}) => - createToastOnce({ message, title: t("FYI"), color: "blue", ...options }); + createToastOnce({ message, ...TOAST_OPTIONS().info, ...options }); /** * Yellow message with "Busy" as the default title. */ export const busy = (message: string, options: ToastOptions = {}) => - createToastOnce({ message, title: t("Busy"), color: "yellow", ...options }); + createToastOnce({ message, ...TOAST_OPTIONS().busy, ...options }); /** * Dark blue message with "Did you know?" as the default title. */ export const fun = (message: string, options: ToastOptions = {}) => - createToastOnce({ - message, - title: t("Did you know?"), - color: "dark-blue", - ...options, - }); + createToastOnce({ message, ...TOAST_OPTIONS().fun, ...options }); /** Remove all toast messages that match the provided id prefix. */ export const removeToast = (idPrefix: string) => { diff --git a/frontend/tools/__tests__/add_tool_test.tsx b/frontend/tools/__tests__/add_tool_test.tsx index 8c52d27502..6b68faf8d8 100644 --- a/frontend/tools/__tests__/add_tool_test.tsx +++ b/frontend/tools/__tests__/add_tool_test.tsx @@ -38,6 +38,12 @@ describe("", () => { expect(wrapper.text().toLowerCase()).toContain("flow rate"); }); + it("renders seeder", () => { + const wrapper = mount(); + wrapper.setState({ toolName: "seeder" }); + expect(wrapper.text().toLowerCase()).toContain("tip z offset"); + }); + it("changes flow rate", () => { const wrapper = shallow(); expect(wrapper.state().flowRate).toEqual(0); @@ -45,6 +51,13 @@ describe("", () => { expect(wrapper.state().flowRate).toEqual(1); }); + it("changes tip z offset", () => { + const wrapper = shallow(); + expect(wrapper.state().tipZOffset).toEqual(80); + wrapper.instance().changeTipZOffset(1); + expect(wrapper.state().tipZOffset).toEqual(1); + }); + it("edits tool name", () => { const wrapper = shallow(); expect(wrapper.state().toolName).toEqual(""); @@ -80,7 +93,9 @@ describe("", () => { wrapper.instance().navigate = navigate; await wrapper.find(SaveBtn).simulate("click"); expect(init).toHaveBeenCalledWith("Tool", { - name: "Foo", flow_rate_ml_per_s: 0, + name: "Foo", + flow_rate_ml_per_s: 0, + seeder_tip_z_offset: 80, }); expect(wrapper.state().uuid).toEqual(undefined); expect(navigate).toHaveBeenCalledWith(Path.tools()); @@ -96,7 +111,9 @@ describe("", () => { wrapper.instance().navigate = navigate; await wrapper.find(SaveBtn).simulate("click"); expect(init).toHaveBeenCalledWith("Tool", { - name: "Foo", flow_rate_ml_per_s: 0, + name: "Foo", + flow_rate_ml_per_s: 0, + seeder_tip_z_offset: 80, }); expect(wrapper.state().uuid).toEqual("fake uuid"); expect(navigate).not.toHaveBeenCalled(); diff --git a/frontend/tools/__tests__/edit_tool_test.tsx b/frontend/tools/__tests__/edit_tool_test.tsx index 75ee125bb9..0c4ab1244b 100644 --- a/frontend/tools/__tests__/edit_tool_test.tsx +++ b/frontend/tools/__tests__/edit_tool_test.tsx @@ -7,10 +7,14 @@ jest.mock("../../api/crud", () => ({ jest.mock("../../devices/actions", () => ({ sendRPC: jest.fn() })); import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { mount, shallow } from "enzyme"; import { RawEditTool as EditTool, mapStateToProps, isActive, WaterFlowRateInput, WaterFlowRateInputProps, LUA_WATER_FLOW_RATE, + TipZOffsetInput, + TipZOffsetInputProps, } from "../edit_tool"; import { fakeTool, fakeToolSlot, @@ -52,6 +56,12 @@ describe("", () => { expect(wrapper.text().toLowerCase()).toContain("flow rate"); }); + it("renders seeder", () => { + const wrapper = mount(); + wrapper.setState({ toolName: "seeder" }); + expect(wrapper.text().toLowerCase()).toContain("tip z offset"); + }); + it("changes flow rate", () => { const wrapper = shallow(); expect(wrapper.state().flowRate).toEqual(0); @@ -59,6 +69,13 @@ describe("", () => { expect(wrapper.state().flowRate).toEqual(1); }); + it("changes tip z offset", () => { + const wrapper = shallow(); + expect(wrapper.state().tipZOffset).toEqual(80); + wrapper.instance().changeTipZOffset(1); + expect(wrapper.state().tipZOffset).toEqual(1); + }); + it("handles missing tool name", () => { const p = fakeProps(); const tool = fakeTool(); @@ -118,7 +135,9 @@ describe("", () => { const wrapper = mountWithContext(); wrapper.find(".save-btn").simulate("click"); expect(edit).toHaveBeenCalledWith(expect.any(Object), { - name: "Foo", flow_rate_ml_per_s: 0, + name: "Foo", + flow_rate_ml_per_s: 0, + seeder_tip_z_offset: 80, }); expect(save).toHaveBeenCalledWith(tool.uuid); expect(mockNavigate).toHaveBeenCalledWith(Path.tools()); @@ -190,18 +209,34 @@ describe("", () => { }); it("sends RPC", () => { - const wrapper = mount(); - wrapper.find("button").first().simulate("click"); + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); expect(sendRPC).toHaveBeenCalledWith({ kind: "lua", args: { lua: LUA_WATER_FLOW_RATE } }); }); - it("changes value", () => { + it("changes value", async () => { + const p = fakeProps(); + render(); + const input = screen.getByRole("spinbutton"); + await userEvent.type(input, "2"); + expect(p.onChange).toHaveBeenCalledWith(12); + }); +}); + +describe("", () => { + const fakeProps = (): TipZOffsetInputProps => ({ + value: 1, + onChange: jest.fn(), + }); + + it("changes value", async () => { const p = fakeProps(); - const wrapper = mount(); - wrapper.find("input").first().simulate("change", - { currentTarget: { value: "1" } }); - expect(p.onChange).toHaveBeenCalledWith(1); + render(); + const input = screen.getByRole("spinbutton"); + await userEvent.type(input, "2"); + expect(p.onChange).toHaveBeenCalledWith(12); }); }); diff --git a/frontend/tools/__tests__/index_test.tsx b/frontend/tools/__tests__/index_test.tsx index ccb6ea0217..dea5bb85c0 100644 --- a/frontend/tools/__tests__/index_test.tsx +++ b/frontend/tools/__tests__/index_test.tsx @@ -321,6 +321,21 @@ describe("", () => { }); }); + it("doesn't open tool slot: disabled", () => { + location.pathname = Path.mock(Path.toolSlots()); + const p = fakeProps(); + p.disableNavigate = true; + p.toolSlot.body.id = 1; + const wrapper = shallow(); + wrapper.find("div").first().simulate("click"); + expect(mapPointClickAction).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(selectPoint).not.toHaveBeenCalled(); + expect(p.dispatch).not.toHaveBeenCalledWith({ + type: Actions.HOVER_TOOL_SLOT, payload: undefined, + }); + }); + it("removes item in box select mode", () => { location.pathname = Path.mock(Path.plants("select")); const p = fakeProps(); diff --git a/frontend/tools/add_tool.tsx b/frontend/tools/add_tool.tsx index a41e9dbc7b..c82c0dd7c1 100644 --- a/frontend/tools/add_tool.tsx +++ b/frontend/tools/add_tool.tsx @@ -25,7 +25,7 @@ import { Path } from "../internal_urls"; import { reduceToolName, ToolName, } from "../farm_designer/map/tool_graphics/all_tools"; -import { WaterFlowRateInput } from "./edit_tool"; +import { TipZOffsetInput, WaterFlowRateInput } from "./edit_tool"; import { NavigationContext } from "../routes_helpers"; export const mapStateToProps = (props: Everything): AddToolProps => ({ @@ -38,7 +38,13 @@ export const mapStateToProps = (props: Everything): AddToolProps => ({ }); export class RawAddTool extends React.Component { - state: AddToolState = { toolName: "", toAdd: [], uuid: undefined, flowRate: 0 }; + state: AddToolState = { + toolName: "", + toAdd: [], + uuid: undefined, + flowRate: 0, + tipZOffset: 80, + }; filterExisting = (n: string) => !this.props.existingToolNames.includes(n); @@ -66,6 +72,7 @@ export class RawAddTool extends React.Component { const initTool = init("Tool", { name: this.state.toolName, flow_rate_ml_per_s: this.state.flowRate, + seeder_tip_z_offset: this.state.tipZOffset, }); this.props.dispatch(initTool); const { uuid } = initTool.payload; @@ -167,6 +174,7 @@ export class RawAddTool extends React.Component { }; changeFlowRate = (flowRate: number) => this.setState({ flowRate }); + changeTipZOffset = (tipZOffset: number) => this.setState({ tipZOffset }); render() { const { toolName, uuid } = this.state; @@ -199,10 +207,13 @@ export class RawAddTool extends React.Component { name="toolName" onChange={e => this.setState({ toolName: e.currentTarget.value })} /> - {reduceToolName(toolName) == ToolName.wateringNozzle && - }
+ {reduceToolName(toolName) == ToolName.wateringNozzle && + } + {reduceToolName(toolName) == ToolName.seeder && + }

{alreadyAdded ? t("Already added.") : ""}

diff --git a/frontend/tools/edit_tool.tsx b/frontend/tools/edit_tool.tsx index 1ff41fa958..e5b8ba8bfe 100644 --- a/frontend/tools/edit_tool.tsx +++ b/frontend/tools/edit_tool.tsx @@ -47,7 +47,7 @@ export interface WaterFlowRateInputProps { } export const WaterFlowRateInput = (props: WaterFlowRateInputProps) => { - return
+ return
{!props.hideTooltip && } @@ -62,6 +62,21 @@ export const WaterFlowRateInput = (props: WaterFlowRateInputProps) => {
; }; +export interface TipZOffsetInputProps { + value: number; + onChange(value: number): void; +} + +export const TipZOffsetInput = (props: TipZOffsetInputProps) => { + return
+ + props.onChange(parseInt(e.currentTarget.value))} /> +
; +}; + export const mapStateToProps = (props: Everything): EditToolProps => ({ findTool: (id: string) => maybeFindToolById(props.resources.index, parseInt(id)), @@ -79,6 +94,7 @@ export class RawEditTool extends React.Component { state: EditToolState = { toolName: this.tool?.body.name || "", flowRate: this.tool?.body.flow_rate_ml_per_s || 0, + tipZOffset: this.tool?.body.seeder_tip_z_offset || 0, }; get stringyID() { return Path.getSlug(Path.tools()); } @@ -98,6 +114,7 @@ export class RawEditTool extends React.Component { }; changeFlowRate = (flowRate: number) => this.setState({ flowRate }); + changeTipZOffset = (tipZOffset: number) => this.setState({ tipZOffset }); default = (tool: TaggedTool) => { const { dispatch } = this.props; @@ -116,6 +133,7 @@ export class RawEditTool extends React.Component { this.props.dispatch(edit(tool, { name: toolName, flow_rate_ml_per_s: this.state.flowRate, + seeder_tip_z_offset: this.state.tipZOffset, })); this.props.dispatch(save(tool.uuid)); this.navigate(Path.tools()); @@ -143,10 +161,13 @@ export class RawEditTool extends React.Component { this.setState({ toolName: e.currentTarget.value })} /> - {reduceToolName(toolName) == ToolName.wateringNozzle && - }
+ {reduceToolName(toolName) == ToolName.wateringNozzle && + } + {reduceToolName(toolName) == ToolName.seeder && + }

{nameTaken ? t("Name already taken.") : ""}

diff --git a/frontend/tools/index.tsx b/frontend/tools/index.tsx index 2ea70ec6d1..01d15fce3e 100644 --- a/frontend/tools/index.tsx +++ b/frontend/tools/index.tsx @@ -232,6 +232,7 @@ export const ToolSlotInventoryItem = (props: ToolSlotInventoryItemProps) => { return
{ + if (props.disableNavigate) { return; } if (getMode() == Mode.boxSelect) { mapPointClickAction(navigate, props.dispatch, props.toolSlot.uuid)(); props.dispatch(setToolHover(undefined)); diff --git a/frontend/tools/interfaces.ts b/frontend/tools/interfaces.ts index c6db9bcaaa..3d7d24b11a 100644 --- a/frontend/tools/interfaces.ts +++ b/frontend/tools/interfaces.ts @@ -26,6 +26,7 @@ export interface AddToolState { toAdd: string[]; uuid: UUID | undefined; flowRate: number; + tipZOffset: number; } export interface EditToolProps { @@ -41,6 +42,7 @@ export interface EditToolProps { export interface EditToolState { toolName: string; flowRate: number; + tipZOffset: number; } export interface ToolTransformProps { @@ -78,6 +80,7 @@ export interface ToolSlotInventoryItemProps { hideDropdown?: boolean; toolTransformProps: ToolTransformProps; noUTM: boolean; + disableNavigate?: boolean; } export interface ToolInventoryItemProps { @@ -150,11 +153,11 @@ export interface SlotLocationInputRowProps { gantryMounted: boolean; onChange(update: Partial>): void; botPosition: BotPosition; - botOnline: boolean; - defaultAxes: string; - arduinoBusy: boolean; - dispatch: Function; - movementState: MovementState; + botOnline?: boolean; + defaultAxes?: string; + arduinoBusy?: boolean; + dispatch?: Function; + movementState?: MovementState; } export interface SlotEditRowsProps { diff --git a/frontend/tools/tool_slot_edit_components.tsx b/frontend/tools/tool_slot_edit_components.tsx index 76b0e5de7e..ea65c87235 100644 --- a/frontend/tools/tool_slot_edit_components.tsx +++ b/frontend/tools/tool_slot_edit_components.tsx @@ -132,14 +132,19 @@ export const SlotLocationInputRow = (props: SlotLocationInputRowProps) => {
)} - + {props.dispatch != undefined + && props.botOnline != undefined + && props.arduinoBusy != undefined + && props.defaultAxes != undefined + && props.movementState != undefined && + }
; }; diff --git a/frontend/ui/__tests__/overlay_test.tsx b/frontend/ui/__tests__/overlay_test.tsx new file mode 100644 index 0000000000..1bab92dc20 --- /dev/null +++ b/frontend/ui/__tests__/overlay_test.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Overlay } from "../overlay"; + +describe("", () => { + it("renders overlay", () => { + render(Overlay content); + expect(screen.getByText("Overlay content")).toBeInTheDocument(); + }); +}); diff --git a/frontend/ui/index.ts b/frontend/ui/index.ts index d0ca7d2aa7..b37989aa66 100644 --- a/frontend/ui/index.ts +++ b/frontend/ui/index.ts @@ -13,6 +13,7 @@ export * from "./input_error"; export * from "./markdown"; export * from "./marked_slider"; export * from "./new_fb_select"; +export * from "./overlay"; export * from "./page"; export * from "./popover"; export * from "./row"; diff --git a/frontend/ui/overlay.tsx b/frontend/ui/overlay.tsx new file mode 100644 index 0000000000..58e9daeb6e --- /dev/null +++ b/frontend/ui/overlay.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { + Overlay2 as BlueprintOverlay, + OverlayProps, +} from "@blueprintjs/core"; + +export const Overlay = (props: OverlayProps) => { + return + {props.children} + ; +}; diff --git a/frontend/ui/popover.tsx b/frontend/ui/popover.tsx index 28d38cd50d..5aa40c9174 100644 --- a/frontend/ui/popover.tsx +++ b/frontend/ui/popover.tsx @@ -1,6 +1,6 @@ import React from "react"; import { - Popover as Popover2, + Popover as BlueprintPopover, PopoverProps as BasePopoverProps, } from "@blueprintjs/core"; @@ -9,9 +9,9 @@ export interface PopoverProps extends BasePopoverProps { } export const Popover = (props: PopoverProps) => { - return - + return + {props.target} - + ; }; diff --git a/frontend/util/__tests__/location_test.ts b/frontend/util/__tests__/location_test.ts index 391a1d6b6e..a8247a33ad 100644 --- a/frontend/util/__tests__/location_test.ts +++ b/frontend/util/__tests__/location_test.ts @@ -3,6 +3,7 @@ jest.mock("../../devices/must_be_online", () => ({ forceOnline: () => mockDemo, })); +import { BotLocationData } from "../../devices/interfaces"; import { validBotLocationData } from "../location"; import { LocationData } from "farmbot"; @@ -44,10 +45,8 @@ describe("validBotLocationData()", () => { it("returns location for demo accounts", () => { mockDemo = true; - localStorage.x = "1"; - localStorage.y = "2"; - localStorage.z = "3"; - const result = validBotLocationData(undefined); + const result = validBotLocationData( + { position: { x: 1, y: 2, z: 3 } } as BotLocationData); expect(result).toEqual({ position: { x: 1, y: 2, z: 3 }, scaled_encoders: { x: 0, y: 0, z: 0 }, diff --git a/frontend/util/__tests__/pwa_test.ts b/frontend/util/__tests__/pwa_test.ts index 0463282635..e75ceef9b1 100644 --- a/frontend/util/__tests__/pwa_test.ts +++ b/frontend/util/__tests__/pwa_test.ts @@ -19,10 +19,12 @@ describe("registerServiceWorker()", () => { configurable: true, }); registerServiceWorker(); - expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + expect(window.addEventListener).toHaveBeenCalledWith( + "load", expect.any(Function)); const loadCallback = (window.addEventListener as jest.Mock).mock.calls[0][1]; loadCallback(); - expect(register).toHaveBeenCalledWith(new URL("/service-worker.js", location.href)); + expect(register).toHaveBeenCalledWith( + new URL("/service-worker.js", location.href)); }); it("fails to register", () => { @@ -33,7 +35,8 @@ describe("registerServiceWorker()", () => { configurable: true, }); registerServiceWorker(); - expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + expect(window.addEventListener).toHaveBeenCalledWith( + "load", expect.any(Function)); const loadCallback = (window.addEventListener as jest.Mock).mock.calls[0][1]; loadCallback(); expect(register).toHaveBeenCalled(); @@ -83,7 +86,8 @@ describe("initPWA", () => { value: { register }, configurable: true, }); initPWA(); - expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + expect(window.addEventListener).toHaveBeenCalledWith( + "load", expect.any(Function)); const loadCallback = (window.addEventListener as jest.Mock).mock.calls .find(c => c[0] === "load")[1]; loadCallback(); diff --git a/frontend/util/location.ts b/frontend/util/location.ts index 65817e41d1..1ad6892e38 100644 --- a/frontend/util/location.ts +++ b/frontend/util/location.ts @@ -18,9 +18,9 @@ export function validBotLocationData( return forceOnline() ? { position: { - x: localStorage.x ? parseFloat("" + localStorage.x) : 0, - y: localStorage.y ? parseFloat("" + localStorage.y) : 0, - z: localStorage.z ? parseFloat("" + localStorage.z) : 0, + x: botLocationData?.position.x ?? 0, + y: botLocationData?.position.y ?? 0, + z: botLocationData?.position.z ?? 0, }, scaled_encoders: { x: 0, y: 0, z: 0 }, raw_encoders: { x: 0, y: 0, z: 0 }, diff --git a/frontend/util/pwa.ts b/frontend/util/pwa.ts index 169aff53c2..a100cb3c12 100644 --- a/frontend/util/pwa.ts +++ b/frontend/util/pwa.ts @@ -10,4 +10,3 @@ export const registerServiceWorker = () => { export const initPWA = () => { registerServiceWorker(); }; - diff --git a/frontend/util/version.ts b/frontend/util/version.ts index e0976af308..6233446737 100644 --- a/frontend/util/version.ts +++ b/frontend/util/version.ts @@ -85,7 +85,6 @@ export enum FbosVersionFallback { } const fallbackData: MinOsFeatureLookup = { - [Feature.farmduino_k18]: "15.4.11", [Feature.express_k12]: MinVersionOverride.NEVER, // available: "15.4.6", [Feature.planted_at_now]: MinVersionOverride.NEVER, }; diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx index 3912089451..1b3554e5f9 100644 --- a/frontend/wizard/__tests__/checks_test.tsx +++ b/frontend/wizard/__tests__/checks_test.tsx @@ -33,6 +33,7 @@ jest.mock("../../messages/actions", () => ({ })); import React from "react"; +import { render, screen } from "@testing-library/react"; import { mount, shallow } from "enzyme"; import { bot } from "../../__test_support__/fake_state/bot"; import { @@ -74,6 +75,10 @@ import { SelectMapOrigin, SensorsCheck, SetHome, + SlotCoordinateRows, + SlotCoordinateRowsProps, + SlotDropdownRows, + SlotDropdownRowsProps, SoilHeightMeasurementCheck, SwapJogButton, SwitchCameraCalibrationMethod, @@ -85,6 +90,7 @@ import { fakeAlert, fakeFarmwareEnv, fakeFarmwareInstallation, fakeFbosConfig, fakeFirmwareConfig, fakeImage, fakeLog, fakePinBinding, fakeTool, + fakeToolSlot, fakeUser, fakeWebAppConfig, } from "../../__test_support__/fake_state/resources"; @@ -94,7 +100,9 @@ import { calibrate } from "../../photos/camera_calibration/actions"; import { FarmwareName } from "../../sequences/step_tiles/tile_execute_script"; import { ExternalUrl } from "../../external_urls"; import { PLACEHOLDER_FARMBOT } from "../../photos/images/image_flipper"; -import { changeBlurableInput, clickButton } from "../../__test_support__/helpers"; +import { + changeBlurableInput, changeBlurableInputRTL, clickButton, +} from "../../__test_support__/helpers"; import { Actions } from "../../constants"; import { tourPath } from "../../help/tours"; import { FBSelect } from "../../ui"; @@ -782,6 +790,55 @@ describe("", () => { }); }); +describe("", () => { + const fakeProps = (): SlotCoordinateRowsProps => ({ + resources: buildResourceIndex([fakeDevice(), fakeToolSlot()]).index, + bot: bot, + dispatch: jest.fn(), + indexValues: [0], + }); + + it("updates slot", () => { + const p = fakeProps(); + render(); + const inputs = screen.getAllByDisplayValue(0); + expect(inputs.length).toEqual(3); + changeBlurableInputRTL(inputs[0], "100"); + expect(edit).toHaveBeenCalledWith(expect.any(Object), { x: 100 }); + expect(save).toHaveBeenCalledWith(expect.any(String)); + expect(screen.getByText("Slot 1")).toBeInTheDocument(); + }); + + it("handles missing slots", () => { + const p = fakeProps(); + p.indexValues = [0, 1]; + render(); + expect(screen.getByText("Slot 1")).toBeInTheDocument(); + }); +}); + +describe("", () => { + const fakeProps = (): SlotDropdownRowsProps => ({ + resources: buildResourceIndex([fakeDevice(), fakeToolSlot(), fakeTool()]).index, + bot: bot, + dispatch: jest.fn(), + indexValues: [0], + }); + + it("shows slots", () => { + const p = fakeProps(); + render(); + expect(screen.getByText("Slot 1")).toBeInTheDocument(); + }); + + it("handles missing slots", () => { + const p = fakeProps(); + p.indexValues = [0, 1]; + render(); + expect(screen.getByText("Slot 1")).toBeInTheDocument(); + }); +}); + describe("", () => { it("starts tour", () => { const p = fakeProps(); diff --git a/frontend/wizard/__tests__/data_test.ts b/frontend/wizard/__tests__/data_test.ts index b437bdbaa7..46287c7d79 100644 --- a/frontend/wizard/__tests__/data_test.ts +++ b/frontend/wizard/__tests__/data_test.ts @@ -51,13 +51,13 @@ describe("data check", () => { expect(expressSteps.length).toBeLessThan(steps.length); }); - it("has the same number of sections for express", () => { + it("has the correct number of sections for express", () => { const sections = WIZARD_SECTIONS({ firmwareHardware: undefined, }); const expressSections = WIZARD_SECTIONS({ firmwareHardware: "express_k10", }); - expect(expressSections.length).toEqual(sections.length); + expect(expressSections.length).toEqual(sections.length - 1); }); }); diff --git a/frontend/wizard/__tests__/step_test.tsx b/frontend/wizard/__tests__/step_test.tsx index 95fb1ae801..770e0df42c 100644 --- a/frontend/wizard/__tests__/step_test.tsx +++ b/frontend/wizard/__tests__/step_test.tsx @@ -8,7 +8,10 @@ import { fakeTimeSettings } from "../../__test_support__/fake_time_settings"; import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; import { bot } from "../../__test_support__/fake_state/bot"; import { WizardSectionSlug, WizardStepSlug } from "../data"; -import { fakeWizardStepResult } from "../../__test_support__/fake_state/resources"; +import { + fakeTool, + fakeToolSlot, fakeWizardStepResult, +} from "../../__test_support__/fake_state/resources"; const fakeWizardStep = (): WizardStep => ({ section: WizardSectionSlug.controls, @@ -193,6 +196,22 @@ describe("", () => { expect(wrapper.find(".controls-check").length).toEqual(2); }); + it("renders slot rows", () => { + const p = fakeProps(); + p.resources = buildResourceIndex([fakeToolSlot()]).index; + p.step.slotInputRows = [0]; + const wrapper = mount(); + expect(wrapper.find(".slot-coordinates").length).toEqual(1); + }); + + it("renders slot tool dropdown rows", () => { + const p = fakeProps(); + p.resources = buildResourceIndex([fakeToolSlot(), fakeTool()]).index; + p.step.slotDropdownRows = [0]; + const wrapper = mount(); + expect(wrapper.find(".slot-coordinates").length).toEqual(1); + }); + it("renders pin bindings", () => { const p = fakeProps(); p.step.pinBindingOptions = { editing: false }; diff --git a/frontend/wizard/checks.tsx b/frontend/wizard/checks.tsx index b32a1c01e1..76495393fb 100644 --- a/frontend/wizard/checks.tsx +++ b/frontend/wizard/checks.tsx @@ -14,6 +14,7 @@ import { selectAllImages, selectAllLogs, selectAllPeripherals, selectAllSensors, selectAllTools, maybeGetTimeSettings, + selectAllToolSlotPointers, } from "../resources/selectors"; import { last, some, uniq } from "lodash"; import { @@ -40,7 +41,7 @@ import { changeFirmwareHardware, SEED_DATA_OPTIONS, SEED_DATA_OPTIONS_DDI, } from "../messages/cards"; import { seedAccount } from "../messages/actions"; -import { FirmwareHardware, TaggedLog, Xyz } from "farmbot"; +import { FirmwareHardware, TaggedLog, TaggedToolSlotPointer, Xyz } from "farmbot"; import { ConnectivityDiagram } from "../devices/connectivity/diagram"; import { Diagnosis } from "../devices/connectivity/diagnosis"; import { connectivityData } from "../devices/connectivity/generate_data"; @@ -98,11 +99,13 @@ import { BotState } from "../devices/interfaces"; import { reduceToolName, ToolName, } from "../farm_designer/map/tool_graphics/all_tools"; -import { WaterFlowRateInput } from "../tools/edit_tool"; +import { isActive, WaterFlowRateInput } from "../tools/edit_tool"; import { RPI_OPTIONS } from "../settings/fbos_settings/rpi_model"; import { BoxTop } from "../settings/pin_bindings/box_top"; import { OtaTimeSelector } from "../settings/fbos_settings/ota_time_selector"; import { useNavigate } from "react-router"; +import { SlotLocationInputRow } from "../tools/tool_slot_edit_components"; +import { ToolSlotInventoryItem } from "../tools"; export const Language = (props: WizardStepComponentProps) => { const user = getUserAccountSettings(props.resources); @@ -805,6 +808,66 @@ export const CameraReplacement = () =>

; +export interface SlotCoordinateRowsProps { + dispatch: Function; + resources: ResourceIndex; + bot: BotState; + indexValues: number[]; +} + +export const SlotCoordinateRows = (props: SlotCoordinateRowsProps) => { + const locationData = validBotLocationData(props.bot.hardware.location_data); + const slots = selectAllToolSlotPointers(props.resources); + return
+ {props.indexValues.map(index => { + const slot = slots[index]; + if (!slot) { return; } + const updateSlot = (update: Partial) => { + props.dispatch(edit(slot, update)); + props.dispatch(save(slot.uuid)); + }; + return
+ + +
; + })} +
; +}; + +export interface SlotDropdownRowsProps { + dispatch: Function; + resources: ResourceIndex; + bot: BotState; + indexValues: number[]; +} + +export const SlotDropdownRows = (props: SlotDropdownRowsProps) => { + const slots = selectAllToolSlotPointers(props.resources); + const tools = selectAllTools(props.resources); + return
+ {props.indexValues.map(index => { + const slot = slots[index]; + if (!slot) { return; } + return
+ + +
; + })} +
; +}; + export const Tour = (tourSlug: string) => { const navigate = useNavigate(); return (props: WizardStepComponentProps) => diff --git a/frontend/wizard/data.ts b/frontend/wizard/data.ts index 4de66cf8c9..b5987a8dcd 100644 --- a/frontend/wizard/data.ts +++ b/frontend/wizard/data.ts @@ -1,5 +1,5 @@ import { t } from "../i18next_wrapper"; -import { round } from "lodash"; +import { range, round } from "lodash"; import { SetupWizardContent, ToolTips } from "../constants"; import { WizardSection, WizardStepDataProps, WizardSteps, WizardToC, WizardToCSection, @@ -83,6 +83,7 @@ export enum WizardSectionSlug { peripherals = "peripherals", camera = "camera", tools = "tools", + slots = "slots", tours = "tours", } @@ -104,6 +105,7 @@ const WIZARD_TOC = title: hasUTM(props.firmwareHardware) ? t("UTM and TOOLS") : t("TOOLS"), steps: [], }, + [WizardSectionSlug.slots]: { title: t("SLOT COORDINATES"), steps: [] }, [WizardSectionSlug.tours]: { title: t("TOURS"), steps: [] }, }; return toc; @@ -172,6 +174,13 @@ export enum WizardStepSlug { rotaryTool = "rotaryTool", rotaryToolForward = "rotaryToolForward", rotaryToolReverse = "rotaryToolReverse", + slotsSetup = "slotsSetup", + slot1Coordinates = "slot1Coordinates", + slot2Coordinates = "slot2Coordinates", + remainingSlotCoordinates = "remainingSlotCoordinates", + loadTools = "loadTools", + seedTrough1 = "seedTrough1", + seedTrough2 = "seedTrough2", appTour = "appTour", gardenTour = "gardenTour", toolsTour = "toolsTour", @@ -1594,6 +1603,88 @@ export const WIZARD_STEPS = (props: WizardStepDataProps): WizardSteps => { ], }] : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.slotsSetup, + title: t("Setup"), + content: t(SetupWizardContent.SLOTS_SETUP), + question: t("Is the watering nozzle in the toolbay?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.slot1Coordinates, + title: t("Slot 1 coordinates"), + content: t(SetupWizardContent.SLOTS_1_COORDINATES), + controlsCheckOptions: {}, + slotInputRows: [0], + question: t("Have you saved the current position to the slot?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.slot2Coordinates, + title: t("Slot 2 coordinates"), + content: t(SetupWizardContent.SLOTS_2_COORDINATES), + controlsCheckOptions: {}, + slotInputRows: [1], + question: t("Have you saved the current position to the slot?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.remainingSlotCoordinates, + title: t("Remaining slot coordinates"), + content: t(SetupWizardContent.SLOTS_REMAINING_COORDINATES), + controlsCheckOptions: {}, + slotInputRows: range(6), + question: t("Have you saved coordinate locations for all of the slots?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.loadTools, + title: t("Load tools"), + content: t(SetupWizardContent.SLOTS_LOAD_TOOLS), + controlsCheckOptions: {}, + slotDropdownRows: range(6), + question: t("Are the physical and virtual configurations matching?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.seedTrough1, + title: t("Seed trough 1"), + content: t(SetupWizardContent.SLOTS_SEED_TROUGH_1), + controlsCheckOptions: {}, + slotInputRows: [6], + question: t("Have you saved the current position to the slot?"), + outcomes: [], + }] + : []), + ...(hasUTM(firmwareHardware) + ? [{ + section: WizardSectionSlug.slots, + slug: WizardStepSlug.seedTrough2, + title: t("Seed trough 2"), + content: t(SetupWizardContent.SLOTS_SEED_TROUGH_2), + controlsCheckOptions: {}, + slotInputRows: [7], + question: t("Have you saved the current position to the slot?"), + outcomes: [], + }] + : []), { section: WizardSectionSlug.tours, slug: WizardStepSlug.appTour, @@ -1634,6 +1725,7 @@ export const WIZARD_SECTIONS = (props: WizardStepDataProps): WizardSection[] => const toC = WIZARD_TOC(props); WIZARD_STEPS(props).map(step => toC[step.section].steps.push(step)); return Object.entries(toC) + .filter(([_sectionSlug, sectionData]) => sectionData.steps.length > 0) .map(([sectionSlug, sectionData]: [WizardSectionSlug, WizardToCSection]) => ({ slug: sectionSlug, ...sectionData })); }; diff --git a/frontend/wizard/interfaces.ts b/frontend/wizard/interfaces.ts index 504cff5f73..5b7ba8817d 100644 --- a/frontend/wizard/interfaces.ts +++ b/frontend/wizard/interfaces.ts @@ -75,6 +75,8 @@ export interface WizardStep { componentOptions?: ComponentOptions; warning?: string; controlsCheckOptions?: ControlsCheckOptions; + slotInputRows?: number[]; + slotDropdownRows?: number[]; pinBindingOptions?: PinBindingOptions; question: string; outcomes: WizardStepOutcome[]; @@ -98,7 +100,6 @@ export type WizardResults = Partial>; export type WizardSectionsOpen = Record; - export interface WizardStepDataProps { firmwareHardware: FirmwareHardware | undefined; getConfigValue?: GetWebAppConfigValue; diff --git a/frontend/wizard/step.tsx b/frontend/wizard/step.tsx index 26b635dd59..bc47aae312 100644 --- a/frontend/wizard/step.tsx +++ b/frontend/wizard/step.tsx @@ -1,3 +1,4 @@ +/** Warning: This file has been ignored by eslint due to a call stack error. */ import React from "react"; import { t } from "../i18next_wrapper"; import { Collapse } from "@blueprintjs/core"; @@ -10,7 +11,9 @@ import { Feedback } from "../help/support"; import moment from "moment"; import { FirmwareNumberSettings, Video } from "./step_components"; import { formatTime } from "../util"; -import { ControlsCheck, PinBinding } from "./checks"; +import { + ControlsCheck, PinBinding, SlotCoordinateRows, SlotDropdownRows, +} from "./checks"; import { SetupWizardContent } from "../constants"; import { ExternalUrl } from "../external_urls"; import { FilePath } from "../internal_urls"; @@ -23,7 +26,12 @@ export const WizardStepHeader = (props: WizardStepHeaderProps) => { const normalStepColor = stepDone ? "green" : "gray"; const stepColor = stepFail ? "red" : normalStepColor; - return
@@ -79,7 +87,7 @@ export const WizardStepContainer = (props: WizardStepContainerProps) => { {step.video &&