From 767c1b8e882b82530319535652d7ba54531a297d Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 26 Jun 2025 13:18:47 -0700 Subject: [PATCH 01/54] upgrade deps (bp6) --- Gemfile.lock | 34 +++++------ frontend/__test_support__/fake_state/app.ts | 1 - frontend/__tests__/hotkeys_test.tsx | 11 ++-- frontend/__tests__/reducer_test.ts | 10 --- frontend/app.tsx | 4 +- frontend/constants.ts | 1 - .../__tests__/missed_step_indicator_test.tsx | 2 +- .../move/__tests__/take_photo_button_test.tsx | 2 +- frontend/css/_blueprint_overrides.scss | 61 ++++++++++++------- frontend/css/app/navbar.scss | 6 +- frontend/css/app/status_ticker.scss | 2 +- frontend/css/components/go_button.scss | 2 +- frontend/css/components/image_flipper.scss | 8 +-- frontend/css/components/widgets.scss | 2 +- frontend/css/farm_designer/farm_designer.scss | 10 +-- .../farm_designer/farm_designer_panels.scss | 12 ++-- frontend/css/global/global.scss | 10 +-- frontend/css/global/inputs.scss | 22 +++---- frontend/css/global/saucers.scss | 6 +- frontend/css/global/sliders.scss | 16 ++--- frontend/css/global/tooltips.scss | 2 +- frontend/css/panels/connectivity.scss | 2 +- frontend/css/panels/controls.scss | 30 ++++----- frontend/css/panels/curves.scss | 8 +-- frontend/css/panels/events.scss | 2 +- frontend/css/panels/help.scss | 6 +- frontend/css/panels/jobs.scss | 4 +- frontend/css/panels/location_info.scss | 2 +- frontend/css/panels/logs.scss | 2 +- frontend/css/panels/peripherals.scss | 2 +- frontend/css/panels/photos.scss | 2 +- frontend/css/panels/plants.scss | 2 +- frontend/css/panels/sequence_steps.scss | 2 +- frontend/css/panels/sequences.scss | 34 +++++------ frontend/css/panels/settings.scss | 8 +-- frontend/css/panels/setup_wizard.scss | 6 +- frontend/css/panels/tools.scss | 8 +-- frontend/help/header.tsx | 3 +- frontend/hotkeys.tsx | 12 ++-- frontend/nav/mobile_menu.tsx | 7 ++- frontend/photos/images/photos.tsx | 3 +- frontend/reducer.ts | 6 -- frontend/routes.tsx | 6 +- .../__tests__/tile_write_pin_test.tsx | 2 +- frontend/ui/__tests__/overlay_test.tsx | 10 +++ frontend/ui/index.ts | 1 + frontend/ui/overlay.tsx | 11 ++++ frontend/ui/popover.tsx | 8 +-- frontend/util/__tests__/pwa_test.ts | 12 ++-- frontend/util/pwa.ts | 1 - lib/tasks/fe.rake | 8 +-- package.json | 44 ++++++------- 52 files changed, 246 insertions(+), 232 deletions(-) create mode 100644 frontend/ui/__tests__/overlay_test.tsx create mode 100644 frontend/ui/overlay.tsx diff --git a/Gemfile.lock b/Gemfile.lock index 1261d23699..9f0be8ea4c 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) @@ -113,11 +113,11 @@ GEM drb (2.2.3) e2mmap (0.1.0) erubi (1.13.1) - factory_bot (6.5.1) + factory_bot (6.5.4) activesupport (>= 6.1.0) - factory_bot_rails (6.4.4) + factory_bot_rails (6.5.0) factory_bot (~> 6.5) - railties (>= 5.0.0) + railties (>= 6.1.0) faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.13.1) @@ -126,7 +126,7 @@ GEM 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) @@ -140,7 +140,7 @@ GEM retriable (>= 2.0, < 4.a) 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.53.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -213,7 +213,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.8) + net-imap (0.5.9) date net-protocol net-pop (0.1.2) @@ -229,7 +229,7 @@ GEM racc (~> 1.4) orm_adapter (0.5.0) os (1.1.4) - ostruct (0.6.1) + ostruct (0.6.2) passenger (6.0.27) rack (>= 1.6.13) rackup (>= 1.0.1) @@ -248,7 +248,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 +291,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) @@ -306,16 +306,16 @@ GEM retriable (3.1.2) rexml (3.4.1) 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,7 +326,7 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.3) + rspec-support (3.13.4) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) scenic (1.8.0) 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/__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 && ", () => { 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/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..052899fed5 100644 --- a/frontend/css/app/navbar.scss +++ b/frontend/css/app/navbar.scss @@ -270,7 +270,7 @@ nav { } .menu-popover { - .bp5-popover-content { + .bp6-popover-content { position: relative; width: 22rem; padding: 0; @@ -312,7 +312,7 @@ nav { } } } - .bp5-popover-arrow { + .bp6-popover-arrow { visibility: hidden; } .fa-user { @@ -328,7 +328,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/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/global.scss b/frontend/css/global/global.scss index 70eda9db00..61c955cf38 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); } @@ -371,8 +371,8 @@ a { position: relative; margin-right: 1rem; } - .bp5-popover-wrapper, - .bp5-popover-target { + .bp6-popover-wrapper, + .bp6-popover-target { margin-left: 1rem; } } @@ -398,7 +398,7 @@ a { button { float: none !important; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 0.5rem; font-size: 1.3rem; 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..1542ffd683 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; } } @@ -247,21 +247,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..f61ff803e7 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 { @@ -68,7 +68,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..5b558671a3 100644 --- a/frontend/css/panels/sequence_steps.scss +++ b/frontend/css/panels/sequence_steps.scss @@ -388,7 +388,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..729b49d137 100644 --- a/frontend/css/panels/sequences.scss +++ b/frontend/css/panels/sequences.scss @@ -72,7 +72,7 @@ h3 { margin-top: 1rem; } - .bp5-popover-target .saucer { + .bp6-popover-target .saucer { float: left; } @media screen and (max-width: 767px) { @@ -187,10 +187,10 @@ 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,10 +224,10 @@ 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; @@ -419,7 +419,7 @@ font-size: 2rem; margin-right: 0.5rem; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: contents; } } @@ -500,7 +500,7 @@ margin: 0 3.5rem; } .location-form-content { - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; margin-left: 1rem; } @@ -545,7 +545,7 @@ &.read-only { margin-top: 1rem; margin-bottom: 1rem; - .bp5-control, + .bp6-control, .filter-search, textarea, .input, @@ -626,7 +626,7 @@ margin-left: -1rem; color: $white; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline; margin-left: 1rem; i { @@ -906,10 +906,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 +924,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 +974,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 +1102,7 @@ } } &.last { - .bp5-collapse { + .bp6-collapse { margin-top: 1rem; margin-right: 2.5rem; } @@ -1168,7 +1168,7 @@ display: inline-block; padding-left: 1rem; } - .bp5-popover-wrapper { + .bp6-popover-wrapper { display: inline-block; margin-left: 1rem; } @@ -1295,7 +1295,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..8d6ca38c44 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 { @@ -264,7 +264,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/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/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/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/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/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/step_tiles/__tests__/tile_write_pin_test.tsx b/frontend/sequences/step_tiles/__tests__/tile_write_pin_test.tsx index bf73fec56d..7fda0c80ce 100644 --- a/frontend/sequences/step_tiles/__tests__/tile_write_pin_test.tsx +++ b/frontend/sequences/step_tiles/__tests__/tile_write_pin_test.tsx @@ -30,7 +30,7 @@ describe("", () => { 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/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__/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/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/lib/tasks/fe.rake b/lib/tasks/fe.rake index 9c7c0e0f1b..baecc9abb4 100644 --- a/lib/tasks/fe.rake +++ b/lib/tasks/fe.rake @@ -38,10 +38,10 @@ EXCLUDE = [ version: "19", }, { - packages: ["three"], - reason: "not working:", - version: "0.175.0", - }, + packages: ["jest", "jest-cli", "jest-environment-jsdom"], + reason: "breaking changes (jsdom window.location) in", + version: "30", + } ] # Load package.json as JSON. diff --git a/package.json b/package.json index 1175679a6f..1827a243a1 100644 --- a/package.json +++ b/package.json @@ -37,26 +37,26 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "5.19.0", - "@blueprintjs/select": "5.3.20", + "@blueprintjs/core": "6.0.0", + "@blueprintjs/select": "6.0.0", "@monaco-editor/react": "4.7.0", - "@parcel/transformer-sass": "2.15.2", - "@parcel/transformer-typescript-tsc": "2.15.2", + "@parcel/transformer-sass": "2.15.4", + "@parcel/transformer-typescript-tsc": "2.15.4", "@react-spring/three": "10.0.1", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", "@rollbar/react": "0.12.1", - "@types/lodash": "4.17.17", + "@types/lodash": "4.17.19", "@types/markdown-it": "14.1.2", - "@types/node": "22.15.21", + "@types/node": "24.0.4", "@types/promise-timeout": "1.3.3", - "@types/react": "19.1.5", + "@types/react": "19.1.8", "@types/react-color": "3.0.13", - "@types/react-dom": "19.1.5", - "@types/three": "0.176.0", + "@types/react-dom": "19.1.6", + "@types/three": "0.177.0", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", - "axios": "1.9.0", + "axios": "1.10.0", "bowser": "2.11.0", "browser-speech": "1.1.1", "delaunator": "5.0.1", @@ -68,9 +68,9 @@ "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.52.2", - "mqtt": "5.13.0", - "npm": "11.4.1", - "parcel": "2.15.2", + "mqtt": "5.13.1", + "npm": "11.4.2", + "parcel": "2.15.4", "process": "0.11.10", "promise-timeout": "1.3.0", "punycode": "1.4.1", @@ -79,14 +79,14 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.6.1", + "react-router": "7.6.2", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", - "rollbar": "2.26.4", + "rollbar": "3.0.0-alpha.1", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.174.0", + "three": "0.177.0", "typescript": "5.8.3", "url": "0.11.4" }, @@ -98,8 +98,8 @@ "@testing-library/user-event": "14.6.1", "@types/delaunator": "5.0.3", "@types/enzyme": "3.10.12", - "@types/jest": "29.5.14", - "@types/readable-stream": "4.0.19", + "@types/jest": "30.0.0", + "@types/readable-stream": "4.0.21", "@types/suncalc": "1.9.2", "@typescript-eslint/eslint-plugin": "7.15.0", "@typescript-eslint/parser": "7.15.0", @@ -107,8 +107,8 @@ "enzyme": "3.11.0", "eslint": "8.57.0", "eslint-plugin-eslint-comments": "3.2.0", - "eslint-plugin-import": "2.31.0", - "eslint-plugin-jest": "28.11.0", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-jest": "29.0.1", "eslint-plugin-no-null": "1.0.2", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", @@ -125,9 +125,9 @@ "raf": "3.4.1", "react-addons-test-utils": "15.6.2", "react-test-renderer": "18.3.1", - "sass": "1.89.0", + "sass": "1.89.2", "sass-lint": "1.13.1", - "ts-jest": "29.3.4", + "ts-jest": "29.4.0", "tslint": "5.20.1" } } From bba871de1986144eef0323304180868b26494c2c Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 26 Jun 2025 22:38:29 -0700 Subject: [PATCH 02/54] clear parcel assets before building --- lib/tasks/api.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/api.rake b/lib/tasks/api.rake index 68593587fe..7bac1be148 100644 --- a/lib/tasks/api.rake +++ b/lib/tasks/api.rake @@ -105,6 +105,7 @@ namespace :api do desc "Don't call this directly. Use `rake assets:precompile`." task parcel_compile: :environment do + clean_assets add_monaco parcel "build" end From ccc4559e777b78201dcba81694f2234b834a9d61 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 28 Jun 2025 21:41:06 -0700 Subject: [PATCH 03/54] fix csp_reports error --- app/controllers/dashboard_controller.rb | 13 ++++++--- spec/controllers/dashboard_spec.rb | 36 ++++++++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) 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/spec/controllers/dashboard_spec.rb b/spec/controllers/dashboard_spec.rb index 19fceee7a0..67715bd394 100644 --- a/spec/controllers/dashboard_spec.rb +++ b/spec/controllers/dashboard_spec.rb @@ -24,14 +24,38 @@ expect { get :main_app, params: { path: "nope.jpg" } }.to raise_error(ActionController::RoutingError) end - it "receives CSP violation reports (malformed JSON)" do - post :csp_reports, body: "NOT JSON ! ! !" - expect(json).to eq(problem: "Crashed while parsing report") + it "receives CSP violation reports: success" do + json_payload = { "csp-report" => { "blocked-uri" => "http://malicious.com" } } + expect(Rollbar).to receive(:info).with("CSP Violation Report", json_payload) + post :csp_reports, body: json_payload.to_json, params: { format: :json } + expect(response).to have_http_status(:no_content) end - it "receives CSP violation reports (JSON)" do - post :csp_reports, body: {}.to_json, params: { format: :json } - expect(json).to eq({}) + it "receives CSP violation reports: JSON parse error" do + malformed_json = "{ this is not valid json" + expect(Rollbar).to receive(:info).with( + "CSP Violation Report", + hash_including( + error: "CSP report parse error", + exception: kind_of(String), + raw: malformed_json + ) + ) + post :csp_reports, body: malformed_json + expect(response).to have_http_status(:no_content) + end + + it "receives CSP violation reports: empty body" do + expect(Rollbar).to receive(:info).with( + "CSP Violation Report", + hash_including( + error: "CSP report parse error", + exception: kind_of(String), + raw: "" + ) + ) + post :csp_reports, body: "" + expect(response).to have_http_status(:no_content) end it "creates a new user" do From 0ace7182c6e4458c55dc75f639a7c471b078bb08 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Mon, 30 Jun 2025 12:14:21 -0700 Subject: [PATCH 04/54] add commit SHAs to release info --- lib/tasks/hook.rake | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/tasks/hook.rake b/lib/tasks/hook.rake index 750e0dbc51..9cda2d9a8b 100644 --- a/lib/tasks/hook.rake +++ b/lib/tasks/hook.rake @@ -34,10 +34,10 @@ def commits_since_last_deploy deploy_commit_found = true break end - commits.push(commit["commit"]["message"].gsub("\n", " ")) + commits.push([commit["commit"]["message"].gsub("\n", " "), commit["sha"]]) end if !deploy_commit_found - commits.push("[LAST DEPLOY COMMIT NOT FOUND]") + commits.push(["[LAST DEPLOY COMMIT NOT FOUND]", "0000000"]) end commits end @@ -50,7 +50,7 @@ def details(environment) web_compare_url = "#{COMPARE_URL_WEB}#{last_deploy_commit}...#{COMMIT_SHA}" output += "<#{web_compare_url}|compare>\n" messages = commits_since_last_deploy.reverse.map do |commit| - output += "\n + #{commit}" + output += "\n + #{commit[0]} | ##{commit[1][0..5]}" end output += "\n" pre = environment == "production" ? "my" : environment From d668a84537258aef33d5e5057275cce05d96c0c3 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Mon, 30 Jun 2025 13:20:05 -0700 Subject: [PATCH 05/54] upgrade deps (postgres:17) --- Gemfile.lock | 4 ++-- docker-compose.yml | 2 +- package.json | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9f0be8ea4c..f6dfac13b1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -175,7 +175,7 @@ GEM concurrent-ruby (~> 1.0) json (2.12.2) jsonapi-renderer (0.2.2) - jwt (2.10.1) + jwt (2.10.2) base64 kaminari (1.2.2) activesupport (>= 4.1.0) @@ -329,7 +329,7 @@ GEM rspec-support (3.13.4) 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) 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/package.json b/package.json index 1827a243a1..8ce40d62be 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@rollbar/react": "0.12.1", "@types/lodash": "4.17.19", "@types/markdown-it": "14.1.2", - "@types/node": "24.0.4", + "@types/node": "24.0.7", "@types/promise-timeout": "1.3.3", "@types/react": "19.1.8", "@types/react-color": "3.0.13", @@ -62,7 +62,7 @@ "delaunator": "5.0.1", "events": "3.3.0", "farmbot": "15.8.11", - "i18next": "25.2.1", + "i18next": "25.3.0", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", @@ -79,14 +79,14 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.6.2", + "react-router": "7.6.3", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", - "rollbar": "3.0.0-alpha.1", + "rollbar": "3.0.0-alpha.2", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.177.0", + "three": "0.178.0", "typescript": "5.8.3", "url": "0.11.4" }, From b16ef3427ed2e2251a63792d8539fe73b3f340a3 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Mon, 30 Jun 2025 16:29:38 -0700 Subject: [PATCH 06/54] add menu configs and sun angle debugger --- frontend/promo/tools.ts | 6 +- .../__tests__/config_overlays_test.tsx | 2 +- frontend/three_d_garden/config_overlays.tsx | 7 +- frontend/three_d_garden/constants.ts | 2 - .../garden/__tests__/sun_test.tsx | 1 + frontend/three_d_garden/garden/sun.tsx | 79 +++++++++++++++---- frontend/three_d_garden/helpers.ts | 2 +- 7 files changed, 74 insertions(+), 25 deletions(-) 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/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/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 4ebee0711b..e8aee3ca58 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -293,8 +293,10 @@ export const PrivateOverlay = (props: OverlayProps) => { + + 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..a873749c45 100644 --- a/frontend/three_d_garden/constants.ts +++ b/frontend/three_d_garden/constants.ts @@ -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/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 = ( From 6f7eeeb8f1b4b1eb03583eb9f9f1abfbdbb22f4c Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 2 Jul 2025 21:53:25 -0700 Subject: [PATCH 07/54] add demo account lua runner --- frontend/constants.ts | 3 + frontend/demo/__tests__/lua_runner_test.ts | 199 ++++++++++++ frontend/demo/lua_runner.ts | 339 +++++++++++++++++++++ frontend/devices/__tests__/actions_test.ts | 20 ++ frontend/devices/__tests__/reducer_test.ts | 36 +++ frontend/devices/actions.ts | 5 + frontend/devices/reducer.ts | 14 + frontend/hacks.d.ts | 2 + frontend/util/__tests__/location_test.ts | 7 +- frontend/util/location.ts | 6 +- package.json | 4 +- 11 files changed, 627 insertions(+), 8 deletions(-) create mode 100644 frontend/demo/__tests__/lua_runner_test.ts create mode 100644 frontend/demo/lua_runner.ts diff --git a/frontend/constants.ts b/frontend/constants.ts index b887727f33..d61a27aace 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2436,6 +2436,9 @@ export enum Actions { SET_NEEDS_VERSION_CHECK = "SET_NEEDS_VERSION_CHECK", SET_MALFORMED_NOTIFICATION_SENT = "SET_MALFORMED_NOTIFICATION_SENT", DEMO_TOGGLE_PIN = "DEMO_TOGGLE_PIN", + DEMO_WRITE_PIN = "DEMO_WRITE_PIN", + DEMO_SET_POSITION = "DEMO_SET_POSITION", + DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", // Draggable PUT_DATA_XFER = "PUT_DATA_XFER", diff --git a/frontend/demo/__tests__/lua_runner_test.ts b/frontend/demo/__tests__/lua_runner_test.ts new file mode 100644 index 0000000000..95d91cfbbf --- /dev/null +++ b/frontend/demo/__tests__/lua_runner_test.ts @@ -0,0 +1,199 @@ +import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; +import { + fakeSequence, fakeToolSlot, +} from "../../__test_support__/fake_state/resources"; +jest.mock("../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ + resources: buildResourceIndex([fakeToolSlot()]), + bot: { hardware: { location_data: undefined } }, + }), + }, +})); + +import { ParameterApplication, TaggedSequence } from "farmbot"; +import { Actions } from "../../constants"; +import { store } from "../../redux/store"; +import { info } from "../../toast/toast"; +import { runDemoSequence } from "../lua_runner"; + +const code = ` + n = variable("Number") + print(n) + api{method = "GET", url = "/api/points"} + api{method = "POST", url = "/api/points"} + api{method = "GET", url = "/api/tools"} + pairs({}) + os.time() + move_absolute(0, 0, 0) + wait(1000) + move_absolute(300, 0, 0) + move_absolute(350, 0, 0) + write_pin(8, "digital", 1) + wait(1000) + write_pin(8, "digital", 0) + send_message("info", "msg", "toast") + send_message("success", "msg", "toast") + set_job_progress("job", { + percent = 50, + status = "working", + time = os.time() * 1000 + }) + set_job_progress("job", { + percent = 100, + status = "Complete", + time = os.time() * 1000 + }) + `; + +describe("runDemoSequence()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + }); + + it("runs sequence", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ kind: "lua", args: { lua: code } }]; + sequence.body.id = 1; + const point = fakeToolSlot(); + const ri = buildResourceIndex([sequence, point]).index; + const variables: ParameterApplication[] = [{ + kind: "parameter_application", + args: { + label: "Number", + data_value: { kind: "numeric", args: { number: 1 } }, + }, + }]; + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + runDemoSequence(ri, sequence.body.id, variables); + jest.runAllTimers(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 100, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 200, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 300, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 350, y: 0, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 8, value: 1 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 8, value: 0 }, + }); + expect(info).toHaveBeenCalledWith("msg"); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 50, + status: "working", + type: "", + file_type: "", + updated_at: expect.any(Number), + time: expect.any(Number), + }], + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 100, + status: "Complete", + type: "", + file_type: "", + updated_at: expect.any(Number), + time: undefined, + }], + }); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("handles missing variables", () => { + const sequence = fakeSequence(); + sequence.body.body = [{ kind: "lua", args: { lua: code } }]; + sequence.body.id = 1; + const ri = buildResourceIndex([sequence]).index; + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith("msg"); + expect(console.log).toHaveBeenCalled(); + expect(console.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; + } + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).not.toHaveBeenCalled(); + expect(console.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; + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).not.toHaveBeenCalled(); + expect(console.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; + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + "Lua call error:", + "[string \"return blah + 5\"]:1: attempt to perform arithmetic " + + "on a nil value (global 'blah')", + ); + }); +}); diff --git a/frontend/demo/lua_runner.ts b/frontend/demo/lua_runner.ts new file mode 100644 index 0000000000..fb3502e8fa --- /dev/null +++ b/frontend/demo/lua_runner.ts @@ -0,0 +1,339 @@ +import { lua, lauxlib, lualib, to_jsstring, to_luastring } from "fengari-web"; +import { findSequenceById, selectAllPoints } from "../resources/selectors"; +import { ResourceIndex } from "../resources/interfaces"; +import { + ParameterApplication, PercentageProgress, SequenceBodyItem, Xyz, +} from "farmbot"; +import { info } from "../toast/toast"; +import { store } from "../redux/store"; +import { Actions } from "../constants"; +import { sortGroupBy } from "../point_groups/point_group_sort"; +import { validBotLocationData } from "../util/location"; + +const runLua = + (luaCode: string, variables: ParameterApplication[]): Action[] => { + const actions: Action[] = []; + const L = lauxlib.luaL_newstate(); // stack: [] + + lua.lua_newtable(L); // stack: [env] + + lauxlib.luaL_requiref(L, to_luastring("_G"), lualib.luaopen_base, 1); + + lua.lua_getfield(L, -1, to_luastring("print")); + lua.lua_setfield(L, -3, to_luastring("print")); + + lua.lua_getfield(L, -1, to_luastring("pairs")); + lua.lua_setfield(L, -3, to_luastring("pairs")); + + lua.lua_pop(L, 1); // stack: [env] + + lauxlib.luaL_requiref(L, to_luastring("math"), lualib.luaopen_math, 1); + lua.lua_setfield(L, -2, to_luastring("math")); + + lauxlib.luaL_requiref(L, to_luastring("table"), lualib.luaopen_table, 1); + lua.lua_setfield(L, -2, to_luastring("table")); + + lua.lua_pushjsfunction(L, () => { + const variableName = to_jsstring(lua.lua_tostring(L, 1)); + const n = variables + .filter(variable => variable.args.label === variableName) + .map(variable => variable.args.data_value)[0]; + if (n?.kind === "numeric") { + lua.lua_pushnumber(L, n.args.number); + } + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("variable")); + + lua.lua_newtable(L); // stack: [env, os] + + lua.lua_pushjsfunction(L, () => { + const now = Math.floor(Date.now() / 1000); + lua.lua_pushnumber(L, now); + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("time")); // stack: [env, os] + lua.lua_setfield(L, -2, to_luastring("os")); // stack: [env] + + lua.lua_pushjsfunction(L, () => { + lua.lua_getfield(L, 1, to_luastring("method")); + const method = to_jsstring(lua.lua_tostring(L, -1)); + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 1, to_luastring("url")); + const url = to_jsstring(lua.lua_tostring(L, -1)); + lua.lua_pop(L, 1); + + if (!(method == "GET" && url == "/api/points")) { return 0; } + + const points = selectAllPoints(store.getState().resources.index); + const results = sortGroupBy("yx_alternating", points).map(p => p.body); + lua.lua_newtable(L); + results.forEach((result, i) => { + lua.lua_newtable(L); + Object.entries(result).forEach(([k, v]) => { + lua.lua_pushstring(L, to_luastring(k)); + + if (typeof v === "string") { + lua.lua_pushstring(L, to_luastring(v)); + } else if (typeof v === "number") { + lua.lua_pushnumber(L, v); + } else if (typeof v === "boolean") { + lua.lua_pushboolean(L, v); + } else { + lua.lua_pushnil(L); + } + lua.lua_settable(L, -3); + }); + lua.lua_rawseti(L, -2, i + 1); + }); + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("api")); + + lua.lua_pushjsfunction(L, () => { + const n = lua.lua_gettop(L); + const args = []; + for (let i = 1; i <= n; i++) { + args.push(to_jsstring(lua.lua_tostring(L, i))); + } + actions.push({ type: "send_message", args: args }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("send_message")); + + lua.lua_pushjsfunction(L, () => { + const jobName = to_jsstring(lua.lua_tostring(L, 1)); + + lua.lua_getfield(L, 2, to_luastring("percent")); + const percent = lua.lua_tonumber(L, -1); + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 2, to_luastring("status")); + const status = to_jsstring(lua.lua_tostring(L, -1)); + lua.lua_pop(L, 1); + + lua.lua_getfield(L, 2, to_luastring("time")); + const time = lua.lua_tonumber(L, -1); + lua.lua_pop(L, 1); + + actions.push({ + type: "set_job_progress", + args: [jobName, percent, status, time], + }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("set_job_progress")); + + lua.lua_pushjsfunction(L, () => { + const n = lua.lua_gettop(L); + const args = []; + for (let i = 1; i <= n; i++) { + args.push(lua.lua_tonumber(L, i)); + } + actions.push({ type: "move_absolute", args: args }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("move_absolute")); + + lua.lua_pushjsfunction(L, () => { + const ms = lua.lua_tonumber(L, 1); + actions.push({ type: "wait", args: [ms] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("wait")); + + lua.lua_pushjsfunction(L, () => { + const pin = lua.lua_tonumber(L, 1); + const value = lua.lua_tonumber(L, 3); + actions.push({ type: "write_pin", args: [pin, value] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("write_pin")); + + const statusLoad = lauxlib.luaL_loadstring(L, to_luastring(luaCode)); + if (statusLoad !== lua.LUA_OK) { + const error = to_jsstring(lua.lua_tostring(L, -1)); + console.error("Lua load error:", error); + 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 errorVal = lua.lua_tostring(L, -1); + const error = to_jsstring(errorVal); + console.error("Lua call error:", error); + return []; + } + return actions; + }; + +export const runDemoSequence = ( + resources: ResourceIndex, + sequenceId: number, + variables: ParameterApplication[] | undefined, +) => { + const sequence = findSequenceById(resources, sequenceId); + const actions: Action[] = []; + (sequence.body.body as SequenceBodyItem[]).map(step => { + if (step.kind === "lua") { + const stepActions = runLua(step.args.lua, variables || []); + actions.push(...stepActions); + } + }); + runActions(actions); +}; + +interface Action { + type: "move_absolute" + | "send_message" + | "wait" + | "write_pin" + | "set_job_progress"; + args: (number | string)[]; +} + +const almostEqual = (a: Record, b: Record) => { + 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: Record, + target: Record, +): Record[] => { + 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 = Math.floor(length / 100); + const chunks: Record[] = []; + for (let i = 0; i <= steps; i++) { + const step = { + x: current.x + direction.x * 100 * i, + y: current.y + direction.y * 100 * i, + z: current.z + direction.z * 100 * i, + }; + chunks.push(step); + } + if (!almostEqual(chunks[chunks.length - 1], target)) { + chunks.push(target); + } + return chunks; +}; + +const expandActions = (actions: Action[]): Action[] => { + const expanded: Action[] = []; + const { position } = validBotLocationData( + store.getState().bot.hardware.location_data); + const current = { + x: position.x as number, + y: position.y as number, + z: position.z as number, + }; + actions.map(action => { + switch (action.type) { + case "move_absolute": + const x = action.args[0] as number; + const y = action.args[1] as number; + const z = action.args[2] as number; + const target = { x, y, z }; + const steps = movementChunks(current, target); + steps.map(position => { + expanded.push({ + type: "wait", + args: [500], + }); + expanded.push({ + type: "move_absolute", + args: [position.x, position.y, position.z], + }); + }); + current.x = target.x; + current.y = target.y; + current.z = target.z; + break; + default: + expanded.push(action); + break; + } + }); + return expanded; +}; + +const runActions = (actions: Action[]) => { + let delay = 0; + expandActions(actions).map(action => { + const getFunc = () => { + switch (action.type) { + case "wait": + const ms = action.args[0] as number; + delay += ms; + return undefined; + case "send_message": + const type = "" + action.args[0]; + const msg = "" + action.args[1]; + if (type == "info") { + return () => { + info(msg); + }; + } + return undefined; + case "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 "write_pin": + const pin = action.args[0] as number; + const value = action.args[1] as number; + return () => { + store.dispatch({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin, 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, + status: status as "working", + type: "", + 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], + }); + }; + } + }; + const func = getFunc(); + func && setTimeout(func, delay); + }); +}; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 31aa73a86b..5d9d4a4b21 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -42,6 +42,10 @@ jest.mock("../../redux/store", () => ({ }, })); +jest.mock("../../demo/lua_runner", () => ({ + runDemoSequence: jest.fn(), +})); + import * as actions from "../actions"; import { fakeFirmwareConfig, fakeFbosConfig, @@ -54,6 +58,7 @@ import { edit, save } from "../../api/crud"; import { DeepPartial } from "../../redux/interfaces"; import { Farmbot } from "farmbot"; import { Path } from "../../internal_urls"; +import { runDemoSequence } from "../../demo/lua_runner"; const replaceDeviceWith = async (d: DeepPartial, cb: Function) => { jest.clearAllMocks(); @@ -194,6 +199,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 +239,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(); diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index 178fbd1120..d5b15eb38a 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -180,4 +180,40 @@ describe("botReducer", () => { const r = botReducer(state, action); expect(r.hardware.pins).toEqual({ 13: { value: 0, mode: 0 } }); }); + + it("writes demo pin", () => { + const state = initialState(); + const action = { + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 13, value: 1 }, + }; + const r = botReducer(state, action); + expect(r.hardware.pins).toEqual({ 13: { value: 1, mode: 0 } }); + }); + + 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, + } + }); + }); }); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 32db3a0fc9..8730555844 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -35,6 +35,7 @@ import { ToastOptions } from "../toast/interfaces"; import { forceOnline } from "./must_be_online"; import { store } from "../redux/store"; import { linkToSetting } from "../settings/maybe_highlight"; +import { runDemoSequence } from "../demo/lua_runner"; const ON = 1, OFF = 0; export type ConfigKey = keyof McuParams; @@ -205,6 +206,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) diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index 8e04e5b998..d8039e3cc4 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); @@ -136,6 +137,19 @@ export const botReducer = generateReducer(initialState()) pin.value = Number(!pin.value); return s; }) + .add<{ pin: number, value: number }>(Actions.DEMO_WRITE_PIN, (s, { payload }) => { + s.hardware.pins[payload.pin] = { mode: 0, 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.PING_OK, (s) => { // Going from "down" to "up" const currentState = s.connectivity.uptime["bot.mqtt"]; 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/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/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/package.json b/package.json index 8ce40d62be..f981f223c3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "test-very-slow": "node --expose-gc ./node_modules/.bin/jest -i --colors --coverage", "test-slow": "node scripts/run.js ./node_modules/.bin/jest -w 3 --colors", - "test": "node scripts/run.js ./node_modules/.bin/jest -w 3 --no-coverage", + "test": "node scripts/run.js ./node_modules/.bin/jest -w 3 --colors --no-coverage", "graph-modules-dot": "./node_modules/.bin/madge --dot ./frontend > module_graph.dot", "graph-modules-svg": "dot -Tsvg module_graph.dot -o module_graph.svg", "typecheck": "node scripts/run.js ./node_modules/typescript/bin/tsc --noEmit", @@ -62,6 +62,8 @@ "delaunator": "5.0.1", "events": "3.3.0", "farmbot": "15.8.11", + "fengari": "0.1.4", + "fengari-web": "0.1.4", "i18next": "25.3.0", "lodash": "4.17.21", "markdown-it": "14.1.0", From e79a1583baad264f17055a9f3b977793561747d3 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 3 Jul 2025 16:31:22 -0700 Subject: [PATCH 08/54] add more demo account bot controls --- frontend/controls/interfaces.ts | 2 +- .../controls/peripherals/peripheral_list.tsx | 2 +- frontend/demo/__tests__/lua_runner_test.ts | 291 ++++++++++++++++-- frontend/demo/lua_runner.ts | 195 ++++++++++-- frontend/devices/__tests__/actions_test.ts | 61 +++- frontend/devices/actions.ts | 45 +-- 6 files changed, 527 insertions(+), 69 deletions(-) diff --git a/frontend/controls/interfaces.ts b/frontend/controls/interfaces.ts index 0601d7b558..7e39d01a95 100644 --- a/frontend/controls/interfaces.ts +++ b/frontend/controls/interfaces.ts @@ -36,7 +36,7 @@ export interface AxisProps { } export interface AxisInputBoxGroupProps { - onCommit: (v: Vector3) => Promise; + onCommit: (v: Vector3) => Promise | undefined; position: BotPosition; disabled: boolean | undefined; locked: boolean; 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/demo/__tests__/lua_runner_test.ts b/frontend/demo/__tests__/lua_runner_test.ts index 95d91cfbbf..d3d8657ff2 100644 --- a/frontend/demo/__tests__/lua_runner_test.ts +++ b/frontend/demo/__tests__/lua_runner_test.ts @@ -1,13 +1,26 @@ import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; import { + fakeFirmwareConfig, fakeSequence, fakeToolSlot, } from "../../__test_support__/fake_state/resources"; +let mockPosition = { x: 0, y: 0, z: 0 }; +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; jest.mock("../../redux/store", () => ({ store: { dispatch: jest.fn(), getState: () => ({ - resources: buildResourceIndex([fakeToolSlot()]), - bot: { hardware: { location_data: undefined } }, + resources: buildResourceIndex([ + fakeToolSlot(), + firmwareConfig, + ]), + bot: { + hardware: { + location_data: { position: mockPosition }, + } + }, }), }, })); @@ -16,7 +29,7 @@ import { ParameterApplication, TaggedSequence } from "farmbot"; import { Actions } from "../../constants"; import { store } from "../../redux/store"; import { info } from "../../toast/toast"; -import { runDemoSequence } from "../lua_runner"; +import { runDemoLuaCode, runDemoSequence } from "../lua_runner"; const code = ` n = variable("Number") @@ -50,6 +63,9 @@ const code = ` describe("runDemoSequence()", () => { beforeEach(() => { localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); }); it("runs sequence", () => { @@ -65,9 +81,6 @@ describe("runDemoSequence()", () => { data_value: { kind: "numeric", args: { number: 1 } }, }, }]; - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); expect(store.dispatch).toHaveBeenCalledWith({ @@ -135,9 +148,6 @@ describe("runDemoSequence()", () => { sequence.body.body = [{ kind: "lua", args: { lua: code } }]; sequence.body.id = 1; const ri = buildResourceIndex([sequence]).index; - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(info).toHaveBeenCalledWith("msg"); @@ -153,9 +163,6 @@ describe("runDemoSequence()", () => { if (ri.references[0]) { (ri.references[0] as TaggedSequence).body.body = undefined; } - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); @@ -167,9 +174,6 @@ describe("runDemoSequence()", () => { sequence.body.body = [{ kind: "lua", args: { lua: "!" } }]; sequence.body.id = 1; const ri = buildResourceIndex([sequence]).index; - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); @@ -184,9 +188,6 @@ describe("runDemoSequence()", () => { sequence.body.body = [{ kind: "lua", args: { lua: "return blah + 5" } }]; sequence.body.id = 1; const ri = buildResourceIndex([sequence]).index; - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); @@ -197,3 +198,257 @@ describe("runDemoSequence()", () => { ); }); }); + +describe("runDemoLuaCode()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + }); + + it("runs print", () => { + runDemoLuaCode("print(\"Hello, world!\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Hello, world!"); + }); + + it("runs print: all", () => { + runDemoLuaCode("local a = 2 + 2\nprint(a, false, true, nil)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("4 false true "); + }); + + it("runs garden_size", () => { + runDemoLuaCode( + "print(garden_size().x)\n" + + "print(garden_size().y)\n" + + "print(garden_size().z)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("1000.0"); + expect(console.log).toHaveBeenCalledWith("2000.0"); + expect(console.log).toHaveBeenCalledWith("500.0"); + }); + + it("runs toast", () => { + runDemoLuaCode("toast(\"info\", \"test\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test"); + }); + + it("runs send_message", () => { + runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test"); + }); + + it("runs find_home: all", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("find_home(\"all\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"all\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"x\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"y\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 3 }, + }); + }); + + it("runs toggle_pin", () => { + runDemoLuaCode("toggle_pin(5)"); + jest.runAllTimers(); + expect(console.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(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, value: 1 }, + }); + }); + + it("runs on", () => { + runDemoLuaCode("on(5)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, value: 1 }, + }); + }); + + it("runs off", () => { + runDemoLuaCode("off(5)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_WRITE_PIN, + payload: { pin: 5, value: 0 }, + }); + }); + + it("runs move_relative", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_relative(1, 0, 0)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 2, y: 2, z: 3 }, + }); + }); + + it("runs move_absolute", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_absolute(1, 0, 0)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 0 }, + }); + }); +}); + +/** + * Lua functions available in the demo runner + * + * builtins/lib: + * [ y ] print + * [ y ] pairs + * [ y ] ipairs + * [ y ] os.time + * [ y ] math. + * [ y ] table. + * + * Other: + * [ y ] move_relative + * + * FBOS: + * [ y ] variable (numeric only) + * [ ] auth_token + * [ y ] api (GET /api/points only) + * [ ] base64.decode + * [ ] base64.encode + * [ ] calibrate_camera + * [ ] check_position + * [ ] complete_job + * [ ] coordinate + * [ ] cs_eval + * [ ] current_hour + * [ ] current_minute + * [ ] current_month + * [ ] current_second + * [ ] detect_weeds + * [ ] dispense + * [ ] emergency_lock + * [ ] emergency_unlock + * [ ] env + * [ ] fbos_version + * [ ] find_axis_length + * [ y ] find_home + * [ ] firmware_version + * [ y ] garden_size + * [ ] gcode + * [ ] get_curve + * [ ] get_device + * [ ] get_fbos_config + * [ ] get_firmware_config + * [ ] get_job + * [ ] get_job_progress + * [ ] get_position + * [ ] get_seed_tray_cell + * [ ] get_xyz + * [ ] get_tool + * [ y ] go_to_home + * [ ] grid + * [ ] group + * [ ] http + * [ ] inspect + * [ ] json.decode + * [ ] json.encode + * [ ] measure_soil_height + * [ ] mount_tool + * [ ] dismount_tool + * [ y ] move_absolute + * [ ] move + * [ ] new_sensor_reading + * [ ] photo_grid + * [ ] read_pin + * [ ] read_status + * [ ] rpc + * [ ] sequence + * [ y ] send_message (info only) + * [ ] debug + * [ y ] toast (info only) + * [ ] safe_z + * [ ] set_job + * [ y ] set_job_progress + * [ ] set_pin_io_mode + * [ ] soft_stop + * [ ] soil_height + * [ ] sort + * [ ] take_photo_raw + * [ ] take_photo + * [ y ] toggle_pin + * [ ] uart.open + * [ ] uart.list + * [ ] update_device + * [ ] update_fbos_config + * [ ] update_firmware_config + * [ ] utc + * [ ] local_time + * [ ] to_unix + * [ ] verify_tool + * [ ] wait_ms + * [ y ] wait + * [ ] water + * [ ] watch_pin + * [ y ] on + * [ y ] off + * [ y ] write_pin (digital only) + */ diff --git a/frontend/demo/lua_runner.ts b/frontend/demo/lua_runner.ts index fb3502e8fa..1eb19b5b02 100644 --- a/frontend/demo/lua_runner.ts +++ b/frontend/demo/lua_runner.ts @@ -2,13 +2,16 @@ import { lua, lauxlib, lualib, to_jsstring, to_luastring } from "fengari-web"; import { findSequenceById, selectAllPoints } from "../resources/selectors"; import { ResourceIndex } from "../resources/interfaces"; import { - ParameterApplication, PercentageProgress, SequenceBodyItem, Xyz, + ParameterApplication, PercentageProgress, SequenceBodyItem, + TaggedFirmwareConfig, Xyz, } from "farmbot"; import { info } from "../toast/toast"; import { store } from "../redux/store"; import { Actions } from "../constants"; import { sortGroupBy } from "../point_groups/point_group_sort"; import { validBotLocationData } from "../util/location"; +import { calculateAxialLengths } from "../controls/move/direction_axes_props"; +import { getFirmwareConfig } from "../resources/getters"; const runLua = (luaCode: string, variables: ParameterApplication[]): Action[] => { @@ -19,12 +22,12 @@ const runLua = lauxlib.luaL_requiref(L, to_luastring("_G"), lualib.luaopen_base, 1); - lua.lua_getfield(L, -1, to_luastring("print")); - lua.lua_setfield(L, -3, to_luastring("print")); - lua.lua_getfield(L, -1, to_luastring("pairs")); lua.lua_setfield(L, -3, to_luastring("pairs")); + lua.lua_getfield(L, -1, to_luastring("ipairs")); + lua.lua_setfield(L, -3, to_luastring("ipairs")); + lua.lua_pop(L, 1); // stack: [env] lauxlib.luaL_requiref(L, to_luastring("math"), lualib.luaopen_math, 1); @@ -33,6 +36,24 @@ const runLua = lauxlib.luaL_requiref(L, to_luastring("table"), lualib.luaopen_table, 1); lua.lua_setfield(L, -2, to_luastring("table")); + 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 += to_jsstring(lua.lua_tostring(L, i)); + } else if (lua.lua_isboolean(L, i)) { + output += lua.lua_toboolean(L, i) ? "true" : "false"; + } else { + output += ""; + } + } + actions.push({ type: "print", args: [output] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("print")); + lua.lua_pushjsfunction(L, () => { const variableName = to_jsstring(lua.lua_tostring(L, 1)); const n = variables @@ -102,6 +123,17 @@ const runLua = }); lua.lua_setfield(L, -2, to_luastring("send_message")); + lua.lua_pushjsfunction(L, () => { + const n = lua.lua_gettop(L); + const args = []; + for (let i = 1; i <= n; i++) { + args.push(to_jsstring(lua.lua_tostring(L, i))); + } + actions.push({ type: "toast", args: args }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("toast")); + lua.lua_pushjsfunction(L, () => { const jobName = to_jsstring(lua.lua_tostring(L, 1)); @@ -136,6 +168,31 @@ const runLua = }); lua.lua_setfield(L, -2, 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(lua.lua_tonumber(L, i)); + } + actions.push({ type: "move_relative", args: args }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("move_relative")); + + lua.lua_pushjsfunction(L, () => { + const axis = to_jsstring(lua.lua_tostring(L, -1)); + actions.push({ type: "find_home", args: [axis] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("find_home")); + + lua.lua_pushjsfunction(L, () => { + const axis = to_jsstring(lua.lua_tostring(L, -1)); + actions.push({ type: "go_to_home", args: [axis] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("go_to_home")); + lua.lua_pushjsfunction(L, () => { const ms = lua.lua_tonumber(L, 1); actions.push({ type: "wait", args: [ms] }); @@ -151,6 +208,45 @@ const runLua = }); lua.lua_setfield(L, -2, to_luastring("write_pin")); + lua.lua_pushjsfunction(L, () => { + const pin = lua.lua_tonumber(L, 1); + actions.push({ type: "toggle_pin", args: [pin] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("toggle_pin")); + + lua.lua_pushjsfunction(L, () => { + const pin = lua.lua_tonumber(L, 1); + actions.push({ type: "on", args: [pin] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("on")); + + lua.lua_pushjsfunction(L, () => { + const pin = lua.lua_tonumber(L, 1); + actions.push({ type: "off", args: [pin] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("off")); + + lua.lua_pushjsfunction(L, () => { + const fwConfig = getFirmwareConfig(store.getState().resources.index); + const firmwareSettings = (fwConfig as TaggedFirmwareConfig).body; + const { x, y, z } = calculateAxialLengths({ firmwareSettings }); + lua.lua_newtable(L); + lua.lua_pushstring(L, to_luastring("x")); + lua.lua_pushnumber(L, x); + lua.lua_settable(L, -3); + lua.lua_pushstring(L, to_luastring("y")); + lua.lua_pushnumber(L, y); + lua.lua_settable(L, -3); + lua.lua_pushstring(L, to_luastring("z")); + lua.lua_pushnumber(L, z); + lua.lua_settable(L, -3); + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("garden_size")); + const statusLoad = lauxlib.luaL_loadstring(L, to_luastring(luaCode)); if (statusLoad !== lua.LUA_OK) { const error = to_jsstring(lua.lua_tostring(L, -1)); @@ -171,6 +267,11 @@ const runLua = return actions; }; +export const runDemoLuaCode = (luaCode: string) => { + const actions = runLua(luaCode, []); + runActions(actions); +}; + export const runDemoSequence = ( resources: ResourceIndex, sequenceId: number, @@ -189,7 +290,15 @@ export const runDemoSequence = ( interface Action { type: "move_absolute" + | "move_relative" + | "toggle_pin" + | "on" + | "off" + | "find_home" + | "go_to_home" + | "toast" | "send_message" + | "print" | "wait" | "write_pin" | "set_job_progress"; @@ -243,27 +352,57 @@ const expandActions = (actions: Action[]): Action[] => { y: position.y as number, z: position.z as number, }; + const addPosition = (position: Record) => { + expanded.push({ + type: "wait", + args: [500], + }); + expanded.push({ + type: "move_absolute", + args: [position.x, position.y, position.z], + }); + }; + const setCurrent = (position: Record) => { + current.x = position.x; + current.y = position.y; + current.z = position.z; + }; actions.map(action => { switch (action.type) { case "move_absolute": - const x = action.args[0] as number; - const y = action.args[1] as number; - const z = action.args[2] as number; - const target = { x, y, z }; - const steps = movementChunks(current, target); - steps.map(position => { - expanded.push({ - type: "wait", - args: [500], - }); - expanded.push({ - type: "move_absolute", - args: [position.x, position.y, position.z], - }); - }); - current.x = target.x; - current.y = target.y; - current.z = target.z; + const target = { + x: action.args[0] as number, + y: action.args[1] as number, + z: action.args[2] as number, + }; + movementChunks(current, target).map(addPosition); + setCurrent(target); + break; + case "move_relative": + const moveRelativeTarget = { + 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).map(addPosition); + setCurrent(moveRelativeTarget); + break; + case "find_home": + case "go_to_home": + const axis = action.args[0] as string; + const homeTarget = { + x: ["all", "x"].includes(axis) ? 0 : current.x, + y: ["all", "y"].includes(axis) ? 0 : current.y, + z: ["all", "z"].includes(axis) ? 0 : current.z, + }; + movementChunks(current, homeTarget).map(addPosition); + setCurrent(homeTarget); + break; + case "on": + case "off": + const pin = action.args[0] as number; + const value = action.type === "on" ? 1 : 0; + expanded.push({ type: "write_pin", args: [pin, value] }); break; default: expanded.push(action); @@ -282,6 +421,7 @@ const runActions = (actions: Action[]) => { const ms = action.args[0] as number; delay += ms; return undefined; + case "toast": case "send_message": const type = "" + action.args[0]; const msg = "" + action.args[1]; @@ -291,6 +431,10 @@ const runActions = (actions: Action[]) => { }; } return undefined; + case "print": + return () => { + console.log(action.args[0]); + }; case "move_absolute": const x = action.args[0] as number; const y = action.args[1] as number; @@ -302,6 +446,13 @@ const runActions = (actions: Action[]) => { 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 value = action.args[1] as number; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 5d9d4a4b21..3341f812e9 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -44,6 +44,7 @@ jest.mock("../../redux/store", () => ({ jest.mock("../../demo/lua_runner", () => ({ runDemoSequence: jest.fn(), + runDemoLuaCode: jest.fn(), })); import * as actions from "../actions"; @@ -58,7 +59,7 @@ import { edit, save } from "../../api/crud"; import { DeepPartial } from "../../redux/interfaces"; import { Farmbot } from "farmbot"; import { Path } from "../../internal_urls"; -import { runDemoSequence } from "../../demo/lua_runner"; +import { runDemoLuaCode, runDemoSequence } from "../../demo/lua_runner"; const replaceDeviceWith = async (d: DeepPartial, cb: Function) => { jest.clearAllMocks(); @@ -353,11 +354,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", () => { @@ -370,12 +385,24 @@ 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()", () => { @@ -514,20 +541,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)"); }); }); @@ -552,21 +575,43 @@ 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("isLog()", () => { diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 8730555844..17ca76ae59 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -35,7 +35,7 @@ import { ToastOptions } from "../toast/interfaces"; import { forceOnline } from "./must_be_online"; import { store } from "../redux/store"; import { linkToSetting } from "../settings/maybe_highlight"; -import { runDemoSequence } from "../demo/lua_runner"; +import { runDemoLuaCode, runDemoSequence } from "../demo/lua_runner"; const ON = 1, OFF = 0; export type ConfigKey = keyof McuParams; @@ -341,7 +341,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) @@ -350,7 +353,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) @@ -399,20 +405,15 @@ export function move(props: MoveProps) { } 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( @@ -437,8 +438,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 }) @@ -447,7 +451,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 }) From 3d50b2f0c47b77de399ee0ed5bf27f0bf595a315 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 8 Jul 2025 19:09:48 -0700 Subject: [PATCH 09/54] add estop and helpers to lua runner --- frontend/constants.ts | 1 + frontend/demo/__tests__/lua_runner_test.ts | 129 +++- frontend/demo/lua_helpers.ts | 735 +++++++++++++++++++++ frontend/demo/lua_runner.ts | 162 ++++- frontend/devices/__tests__/actions_test.ts | 20 + frontend/devices/__tests__/reducer_test.ts | 16 + frontend/devices/actions.ts | 10 +- frontend/devices/reducer.ts | 4 + frontend/toast/constants.ts | 12 + frontend/toast/toast.ts | 21 +- 10 files changed, 1050 insertions(+), 60 deletions(-) create mode 100644 frontend/demo/lua_helpers.ts create mode 100644 frontend/toast/constants.ts diff --git a/frontend/constants.ts b/frontend/constants.ts index d61a27aace..d1dfe90507 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2439,6 +2439,7 @@ export enum Actions { DEMO_WRITE_PIN = "DEMO_WRITE_PIN", DEMO_SET_POSITION = "DEMO_SET_POSITION", DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", + DEMO_SET_ESTOP = "DEMO_SET_ESTOP", // Draggable PUT_DATA_XFER = "PUT_DATA_XFER", diff --git a/frontend/demo/__tests__/lua_runner_test.ts b/frontend/demo/__tests__/lua_runner_test.ts index d3d8657ff2..b0676780d9 100644 --- a/frontend/demo/__tests__/lua_runner_test.ts +++ b/frontend/demo/__tests__/lua_runner_test.ts @@ -3,6 +3,7 @@ import { fakeFirmwareConfig, fakeSequence, fakeToolSlot, } from "../../__test_support__/fake_state/resources"; +let mockLocked = false; let mockPosition = { x: 0, y: 0, z: 0 }; const firmwareConfig = fakeFirmwareConfig(); firmwareConfig.body.movement_axis_nr_steps_x = 5000; @@ -19,7 +20,8 @@ jest.mock("../../redux/store", () => ({ bot: { hardware: { location_data: { position: mockPosition }, - } + informational_settings: { locked: mockLocked }, + }, }, }), }, @@ -30,6 +32,7 @@ import { Actions } from "../../constants"; import { store } from "../../redux/store"; import { info } from "../../toast/toast"; import { runDemoLuaCode, runDemoSequence } from "../lua_runner"; +import { TOAST_OPTIONS } from "../../toast/constants"; const code = ` n = variable("Number") @@ -115,7 +118,7 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_WRITE_PIN, payload: { pin: 8, value: 0 }, }); - expect(info).toHaveBeenCalledWith("msg"); + expect(info).toHaveBeenCalledWith("msg", TOAST_OPTIONS().info); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_JOB_PROGRESS, payload: ["job", { @@ -150,7 +153,9 @@ describe("runDemoSequence()", () => { const ri = buildResourceIndex([sequence]).index; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(info).toHaveBeenCalledWith("msg"); + expect(info).toHaveBeenCalledWith( + "Variables of type other than numeric not implemented.", + TOAST_OPTIONS().error); expect(console.log).toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled(); }); @@ -193,8 +198,7 @@ describe("runDemoSequence()", () => { expect(console.log).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalledWith( "Lua call error:", - "[string \"return blah + 5\"]:1: attempt to perform arithmetic " + - "on a nil value (global 'blah')", + expect.stringContaining("attempt to perform arithmetic"), ); }); }); @@ -205,6 +209,7 @@ describe("runDemoLuaCode()", () => { console.log = jest.fn(); console.error = jest.fn(); jest.useFakeTimers(); + mockLocked = false; }); it("runs print", () => { @@ -233,18 +238,40 @@ describe("runDemoLuaCode()", () => { expect(console.log).toHaveBeenCalledWith("500.0"); }); + it("runs api: default method", () => { + runDemoLuaCode( + "local data = api{url=\"/api/points\"}\nprint(type(data), #data)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + }); + it("runs toast", () => { - runDemoLuaCode("toast(\"info\", \"test\")"); + runDemoLuaCode("toast(\"test\", \"info\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs toast: default", () => { + runDemoLuaCode("toast(\"test\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs debug", () => { + runDemoLuaCode("debug(\"test\")"); jest.runAllTimers(); expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test"); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().debug); }); it("runs send_message", () => { runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); jest.runAllTimers(); expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test"); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); }); it("runs find_home: all", () => { @@ -321,6 +348,14 @@ describe("runDemoLuaCode()", () => { }); }); + it("doesn't run when estopped", () => { + mockLocked = true; + runDemoLuaCode("on(5)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + it("runs off", () => { runDemoLuaCode("off(5)"); jest.runAllTimers(); @@ -352,6 +387,46 @@ describe("runDemoLuaCode()", () => { payload: { x: 1, y: 0, z: 0 }, }); }); + + it("runs emergency_lock", () => { + runDemoLuaCode("emergency_lock()"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + }); + + it("runs emergency_unlock", () => { + runDemoLuaCode("emergency_unlock()"); + jest.runAllTimers(); + expect(console.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(console.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).toHaveBeenCalledWith( + "Lua function \"foo.bar.baz\" is not implemented.", + TOAST_OPTIONS().error, + ); + }); }); /** @@ -359,6 +434,8 @@ describe("runDemoLuaCode()", () => { * * builtins/lib: * [ y ] print + * [ y ] type + * [ y ] tostring * [ y ] pairs * [ y ] ipairs * [ y ] os.time @@ -367,6 +444,12 @@ describe("runDemoLuaCode()", () => { * * Other: * [ y ] move_relative + * [ y ] round + * [ y ] angleRound + * [ y ] cropAmount + * [ y ] fwe + * [ y ] axis_overwrite + * [ y ] speed_overwrite * * FBOS: * [ y ] variable (numeric only) @@ -384,9 +467,9 @@ describe("runDemoLuaCode()", () => { * [ ] current_month * [ ] current_second * [ ] detect_weeds - * [ ] dispense - * [ ] emergency_lock - * [ ] emergency_unlock + * [ y ] dispense + * [ y ] emergency_lock + * [ y ] emergency_unlock * [ ] env * [ ] fbos_version * [ ] find_axis_length @@ -394,34 +477,34 @@ describe("runDemoLuaCode()", () => { * [ ] firmware_version * [ y ] garden_size * [ ] gcode - * [ ] get_curve + * [ y ] get_curve * [ ] get_device * [ ] get_fbos_config * [ ] get_firmware_config * [ ] get_job * [ ] get_job_progress * [ ] get_position - * [ ] get_seed_tray_cell + * [ y ] get_seed_tray_cell * [ ] get_xyz * [ ] get_tool * [ y ] go_to_home - * [ ] grid + * [ y ] grid * [ ] group * [ ] http * [ ] inspect * [ ] json.decode * [ ] json.encode * [ ] measure_soil_height - * [ ] mount_tool - * [ ] dismount_tool + * [ y ] mount_tool + * [ y ] dismount_tool * [ y ] move_absolute - * [ ] move + * [ y ] move * [ ] new_sensor_reading - * [ ] photo_grid + * [ y ] photo_grid * [ ] read_pin * [ ] read_status - * [ ] rpc - * [ ] sequence + * [ y ] rpc + * [ y ] sequence * [ y ] send_message (info only) * [ ] debug * [ y ] toast (info only) @@ -443,10 +526,10 @@ describe("runDemoLuaCode()", () => { * [ ] utc * [ ] local_time * [ ] to_unix - * [ ] verify_tool - * [ ] wait_ms + * [ y ] verify_tool + * [ y ] wait_ms * [ y ] wait - * [ ] water + * [ y ] water * [ ] watch_pin * [ y ] on * [ y ] off diff --git a/frontend/demo/lua_helpers.ts b/frontend/demo/lua_helpers.ts new file mode 100644 index 0000000000..0d521e9472 --- /dev/null +++ b/frontend/demo/lua_helpers.ts @@ -0,0 +1,735 @@ +/* eslint-disable max-len */ +export const LUA_HELPERS = ` +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 = fwe("total_rotation_angle") or 0 + local scale = fwe("coord_scale") or 1 + local z = fwe("camera_z") or 0 + local x_offset_mm = fwe("camera_offset_x") or 0 + local y_offset_mm = fwe("camera_offset_y") or 0 + local center_pixel_location_x = fwe("center_pixel_location_x") or 320 + local center_pixel_location_y = fwe("center_pixel_location_y") or 240 + 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) + + -- Dismount tool + job(90, "Dismounting tool") + move{z = slot.z + 50} + + -- Check verification pin + if read_pin(63) == 0 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") + update_device({mounted_tool_id = 0}) + 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} + + -- 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 then + job(80, "Failed") + toast("Tool mounting failed - no electrical connection between UTM pins B and C.", "error") + return + else + job(100, "Complete") + update_device({mounted_tool_id = slot.tool_id}) + 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 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.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_xy + + 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_xy .. " " .. water_ml .. "mL") + dispense(water_ml, params) + complete_job(job_name) +end +`; diff --git a/frontend/demo/lua_runner.ts b/frontend/demo/lua_runner.ts index 1eb19b5b02..362aac3b8e 100644 --- a/frontend/demo/lua_runner.ts +++ b/frontend/demo/lua_runner.ts @@ -12,6 +12,39 @@ import { sortGroupBy } from "../point_groups/point_group_sort"; import { validBotLocationData } from "../util/location"; import { calculateAxialLengths } from "../controls/move/direction_axes_props"; import { getFirmwareConfig } from "../resources/getters"; +import { LUA_HELPERS } from "./lua_helpers"; +import { TOAST_OPTIONS } from "../toast/constants"; + +const createRecursiveNotImplemented = ( + L: unknown, + actions: Action[], + path: string[], +) => { + lua.lua_newtable(L); + lua.lua_newtable(L); + lua.lua_pushjsfunction(L, () => { + const key: string = to_jsstring(lua.lua_tostring(L, 2)); + 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: "toast", + args: [ + `Lua function "${fullPath}" is not implemented.`, + "error", + ], + }); + lua.lua_pushboolean(L, false); + return 1; + }); + lua.lua_setfield(L, -2, to_luastring("__call")); + + lua.lua_setmetatable(L, -2); + return 1; +}; const runLua = (luaCode: string, variables: ParameterApplication[]): Action[] => { @@ -22,6 +55,12 @@ const runLua = lauxlib.luaL_requiref(L, to_luastring("_G"), lualib.luaopen_base, 1); + lua.lua_getfield(L, -1, to_luastring("type")); + lua.lua_setfield(L, -3, to_luastring("type")); + + lua.lua_getfield(L, -1, to_luastring("tostring")); + lua.lua_setfield(L, -3, to_luastring("tostring")); + lua.lua_getfield(L, -1, to_luastring("pairs")); lua.lua_setfield(L, -3, to_luastring("pairs")); @@ -61,6 +100,15 @@ const runLua = .map(variable => variable.args.data_value)[0]; if (n?.kind === "numeric") { lua.lua_pushnumber(L, n.args.number); + } else { + actions.push({ + type: "toast", + args: [ + "Variables of type other than numeric not implemented.", + "error", + ], + }); + lua.lua_pushnil(L); } return 1; }); @@ -78,14 +126,26 @@ const runLua = lua.lua_pushjsfunction(L, () => { lua.lua_getfield(L, 1, to_luastring("method")); - const method = to_jsstring(lua.lua_tostring(L, -1)); + const method = lua.lua_isnil(L, -1) + ? "GET" + : to_jsstring(lua.lua_tostring(L, -1)); lua.lua_pop(L, 1); lua.lua_getfield(L, 1, to_luastring("url")); const url = to_jsstring(lua.lua_tostring(L, -1)); lua.lua_pop(L, 1); - if (!(method == "GET" && url == "/api/points")) { return 0; } + if (!(method == "GET" && url == "/api/points")) { + actions.push({ + type: "toast", + args: [ + "API calls other than GET /api/points not implemented.", + "error", + ], + }); + lua.lua_pushboolean(L, false); + return 1; + } const points = selectAllPoints(store.getState().resources.index); const results = sortGroupBy("yx_alternating", points).map(p => p.body); @@ -124,16 +184,22 @@ const runLua = lua.lua_setfield(L, -2, to_luastring("send_message")); lua.lua_pushjsfunction(L, () => { - const n = lua.lua_gettop(L); - const args = []; - for (let i = 1; i <= n; i++) { - args.push(to_jsstring(lua.lua_tostring(L, i))); - } - actions.push({ type: "toast", args: args }); + const msg = to_jsstring(lua.lua_tostring(L, 1)); + const type = lua.lua_gettop(L) >= 2 + ? to_jsstring(lua.lua_tostring(L, 2)) + : "info"; + actions.push({ type: "toast", args: [msg, type] }); return 0; }); lua.lua_setfield(L, -2, to_luastring("toast")); + lua.lua_pushjsfunction(L, () => { + const msg = to_jsstring(lua.lua_tostring(L, 1)); + actions.push({ type: "toast", args: [msg, "debug"] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("debug")); + lua.lua_pushjsfunction(L, () => { const jobName = to_jsstring(lua.lua_tostring(L, 1)); @@ -195,10 +261,10 @@ const runLua = lua.lua_pushjsfunction(L, () => { const ms = lua.lua_tonumber(L, 1); - actions.push({ type: "wait", args: [ms] }); + actions.push({ type: "wait_ms", args: [ms] }); return 0; }); - lua.lua_setfield(L, -2, to_luastring("wait")); + lua.lua_setfield(L, -2, to_luastring("wait_ms")); lua.lua_pushjsfunction(L, () => { const pin = lua.lua_tonumber(L, 1); @@ -247,6 +313,31 @@ const runLua = }); lua.lua_setfield(L, -2, 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, -2, to_luastring("emergency_lock")); + + lua.lua_pushjsfunction(L, () => { + actions.push({ type: "emergency_unlock", args: [] }); + return 0; + }); + lua.lua_setfield(L, -2, to_luastring("emergency_unlock")); + + lua.lua_newtable(L); + lua.lua_pushjsfunction(L, () => { + const key: string = to_jsstring(lua.lua_tostring(L, 2)); + 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 error = to_jsstring(lua.lua_tostring(L, -1)); @@ -294,12 +385,14 @@ interface Action { | "toggle_pin" | "on" | "off" + | "emergency_lock" + | "emergency_unlock" | "find_home" | "go_to_home" | "toast" | "send_message" | "print" - | "wait" + | "wait_ms" | "write_pin" | "set_job_progress"; args: (number | string)[]; @@ -354,7 +447,7 @@ const expandActions = (actions: Action[]): Action[] => { }; const addPosition = (position: Record) => { expanded.push({ - type: "wait", + type: "wait_ms", args: [500], }); expanded.push({ @@ -404,6 +497,14 @@ const expandActions = (actions: Action[]): Action[] => { const value = action.type === "on" ? 1 : 0; expanded.push({ type: "write_pin", args: [pin, value] }); break; + case "toast": + const msg = "" + action.args[0]; + const type = "" + action.args[1]; + expanded.push({ + type: "send_message", + args: [type, msg], + }); + break; default: expanded.push(action); break; @@ -412,29 +513,48 @@ const expandActions = (actions: Action[]): Action[] => { return expanded; }; +const pending = new Set>(); + const runActions = (actions: Action[]) => { let delay = 0; expandActions(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") { + return; + } switch (action.type) { - case "wait": + case "wait_ms": const ms = action.args[0] as number; delay += ms; return undefined; - case "toast": case "send_message": const type = "" + action.args[0]; const msg = "" + action.args[1]; - if (type == "info") { - return () => { - info(msg); - }; - } - return undefined; + return () => { + info(msg, TOAST_OPTIONS()[type]); + }; case "print": return () => { console.log(action.args[0]); }; + case "emergency_lock": + return () => { + pending.forEach(clearTimeout); + pending.clear(); + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + }; + case "emergency_unlock": + return () => { + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: false, + }); + }; case "move_absolute": const x = action.args[0] as number; const y = action.args[1] as number; @@ -485,6 +605,6 @@ const runActions = (actions: Action[]) => { } }; const func = getFunc(); - func && setTimeout(func, delay); + func && pending.add(setTimeout(func, delay)); }); }; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 3341f812e9..e592e17683 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -148,17 +148,37 @@ describe("flashFirmware()", () => { }); describe("emergencyLock() / emergencyUnlock", () => { + beforeEach(() => { + 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).toHaveBeenCalledWith("emergency_lock()"); + }); + 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(); diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index d5b15eb38a..e32cc3d56b 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -216,4 +216,20 @@ describe("botReducer", () => { } }); }); + + 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); + }); + }); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 17ca76ae59..6afb0afaa8 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -161,7 +161,10 @@ export function flashFirmware(firmwareName: FirmwareHardware) { export function emergencyLock() { const noun = t("Emergency stop"); - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode("emergency_lock()"); + return; + } getDevice() .emergencyLock() .then(commandOK(noun), commandErr(noun)); @@ -170,7 +173,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)); diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index d8039e3cc4..7b9377b0a5 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -150,6 +150,10 @@ export const botReducer = generateReducer(initialState()) s.hardware.jobs[payload[0]] = payload[1]; return s; }) + .add(Actions.DEMO_SET_ESTOP, (s, { payload }) => { + s.hardware.informational_settings.locked = payload; + return s; + }) .add(Actions.PING_OK, (s) => { // Going from "down" to "up" const currentState = s.connectivity.uptime["bot.mqtt"]; 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) => { From 0f0539f149a68d901c02140de1776258a9c8af8e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 11 Jul 2025 10:11:48 -0700 Subject: [PATCH 10/54] add more to and refactor lua runner --- frontend/constants.ts | 1 + frontend/demo/__tests__/lua_runner_test.ts | 537 ---------- frontend/demo/lua_runner.ts | 610 ------------ .../demo/lua_runner/__tests__/actions_test.ts | 14 + .../demo/lua_runner/__tests__/index_test.ts | 932 ++++++++++++++++++ .../demo/lua_runner/__tests__/lua_test.ts | 7 + .../demo/lua_runner/__tests__/run_test.ts | 18 + .../demo/lua_runner/__tests__/util_test.ts | 133 +++ frontend/demo/lua_runner/actions.ts | 229 +++++ frontend/demo/lua_runner/index.ts | 29 + frontend/demo/lua_runner/interfaces.ts | 18 + .../{lua_helpers.ts => lua_runner/lua.ts} | 89 +- frontend/demo/lua_runner/run.ts | 365 +++++++ frontend/demo/lua_runner/util.ts | 151 +++ frontend/devices/__tests__/actions_test.ts | 19 +- frontend/devices/__tests__/reducer_test.ts | 15 +- frontend/devices/actions.ts | 7 +- frontend/devices/reducer.ts | 10 +- 18 files changed, 2021 insertions(+), 1163 deletions(-) delete mode 100644 frontend/demo/__tests__/lua_runner_test.ts delete mode 100644 frontend/demo/lua_runner.ts create mode 100644 frontend/demo/lua_runner/__tests__/actions_test.ts create mode 100644 frontend/demo/lua_runner/__tests__/index_test.ts create mode 100644 frontend/demo/lua_runner/__tests__/lua_test.ts create mode 100644 frontend/demo/lua_runner/__tests__/run_test.ts create mode 100644 frontend/demo/lua_runner/__tests__/util_test.ts create mode 100644 frontend/demo/lua_runner/actions.ts create mode 100644 frontend/demo/lua_runner/index.ts create mode 100644 frontend/demo/lua_runner/interfaces.ts rename frontend/demo/{lua_helpers.ts => lua_runner/lua.ts} (93%) create mode 100644 frontend/demo/lua_runner/run.ts create mode 100644 frontend/demo/lua_runner/util.ts diff --git a/frontend/constants.ts b/frontend/constants.ts index d1dfe90507..a23d8642e4 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2440,6 +2440,7 @@ export enum Actions { DEMO_SET_POSITION = "DEMO_SET_POSITION", DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", DEMO_SET_ESTOP = "DEMO_SET_ESTOP", + DEMO_SET_MOUNTED_TOOL_ID = "DEMO_SET_MOUNTED_TOOL_ID", // Draggable PUT_DATA_XFER = "PUT_DATA_XFER", diff --git a/frontend/demo/__tests__/lua_runner_test.ts b/frontend/demo/__tests__/lua_runner_test.ts deleted file mode 100644 index b0676780d9..0000000000 --- a/frontend/demo/__tests__/lua_runner_test.ts +++ /dev/null @@ -1,537 +0,0 @@ -import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; -import { - fakeFirmwareConfig, - fakeSequence, fakeToolSlot, -} from "../../__test_support__/fake_state/resources"; -let mockLocked = false; -let mockPosition = { x: 0, y: 0, z: 0 }; -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; -jest.mock("../../redux/store", () => ({ - store: { - dispatch: jest.fn(), - getState: () => ({ - resources: buildResourceIndex([ - fakeToolSlot(), - firmwareConfig, - ]), - bot: { - hardware: { - location_data: { position: mockPosition }, - informational_settings: { locked: mockLocked }, - }, - }, - }), - }, -})); - -import { ParameterApplication, TaggedSequence } from "farmbot"; -import { Actions } from "../../constants"; -import { store } from "../../redux/store"; -import { info } from "../../toast/toast"; -import { runDemoLuaCode, runDemoSequence } from "../lua_runner"; -import { TOAST_OPTIONS } from "../../toast/constants"; - -const code = ` - n = variable("Number") - print(n) - api{method = "GET", url = "/api/points"} - api{method = "POST", url = "/api/points"} - api{method = "GET", url = "/api/tools"} - pairs({}) - os.time() - move_absolute(0, 0, 0) - wait(1000) - move_absolute(300, 0, 0) - move_absolute(350, 0, 0) - write_pin(8, "digital", 1) - wait(1000) - write_pin(8, "digital", 0) - send_message("info", "msg", "toast") - send_message("success", "msg", "toast") - set_job_progress("job", { - percent = 50, - status = "working", - time = os.time() * 1000 - }) - set_job_progress("job", { - percent = 100, - status = "Complete", - time = os.time() * 1000 - }) - `; - -describe("runDemoSequence()", () => { - beforeEach(() => { - localStorage.setItem("myBotIs", "online"); - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); - }); - - it("runs sequence", () => { - const sequence = fakeSequence(); - sequence.body.body = [{ kind: "lua", args: { lua: code } }]; - sequence.body.id = 1; - const point = fakeToolSlot(); - const ri = buildResourceIndex([sequence, point]).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(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 0, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 0, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 100, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 200, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 300, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 350, y: 0, z: 0 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin: 8, value: 1 }, - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin: 8, value: 0 }, - }); - expect(info).toHaveBeenCalledWith("msg", TOAST_OPTIONS().info); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_JOB_PROGRESS, - payload: ["job", { - unit: "percent", - percent: 50, - status: "working", - type: "", - file_type: "", - updated_at: expect.any(Number), - time: expect.any(Number), - }], - }); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_JOB_PROGRESS, - payload: ["job", { - unit: "percent", - percent: 100, - status: "Complete", - type: "", - file_type: "", - updated_at: expect.any(Number), - time: undefined, - }], - }); - expect(console.error).not.toHaveBeenCalled(); - }); - - it("handles missing variables", () => { - const sequence = fakeSequence(); - sequence.body.body = [{ kind: "lua", args: { lua: code } }]; - sequence.body.id = 1; - const ri = buildResourceIndex([sequence]).index; - runDemoSequence(ri, sequence.body.id, undefined); - jest.runAllTimers(); - expect(info).toHaveBeenCalledWith( - "Variables of type other than numeric not implemented.", - TOAST_OPTIONS().error); - expect(console.log).toHaveBeenCalled(); - expect(console.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).not.toHaveBeenCalled(); - expect(console.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).not.toHaveBeenCalled(); - expect(console.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).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - "Lua call error:", - expect.stringContaining("attempt to perform arithmetic"), - ); - }); -}); - -describe("runDemoLuaCode()", () => { - beforeEach(() => { - localStorage.setItem("myBotIs", "online"); - console.log = jest.fn(); - console.error = jest.fn(); - jest.useFakeTimers(); - mockLocked = false; - }); - - it("runs print", () => { - runDemoLuaCode("print(\"Hello, world!\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith("Hello, world!"); - }); - - it("runs print: all", () => { - runDemoLuaCode("local a = 2 + 2\nprint(a, false, true, nil)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith("4 false true "); - }); - - it("runs garden_size", () => { - runDemoLuaCode( - "print(garden_size().x)\n" + - "print(garden_size().y)\n" + - "print(garden_size().z)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith("1000.0"); - expect(console.log).toHaveBeenCalledWith("2000.0"); - expect(console.log).toHaveBeenCalledWith("500.0"); - }); - - it("runs api: default method", () => { - runDemoLuaCode( - "local data = api{url=\"/api/points\"}\nprint(type(data), #data)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledWith("table 1"); - }); - - it("runs toast", () => { - runDemoLuaCode("toast(\"test\", \"info\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); - }); - - it("runs toast: default", () => { - runDemoLuaCode("toast(\"test\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); - }); - - it("runs debug", () => { - runDemoLuaCode("debug(\"test\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().debug); - }); - - it("runs send_message", () => { - runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); - }); - - it("runs find_home: all", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("find_home(\"all\")"); - jest.runAllTimers(); - expect(console.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", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("go_to_home(\"all\")"); - jest.runAllTimers(); - expect(console.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", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("go_to_home(\"x\")"); - jest.runAllTimers(); - expect(console.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", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("go_to_home(\"y\")"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 0, z: 3 }, - }); - }); - - it("runs toggle_pin", () => { - runDemoLuaCode("toggle_pin(5)"); - jest.runAllTimers(); - expect(console.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(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin: 5, value: 1 }, - }); - }); - - it("runs on", () => { - runDemoLuaCode("on(5)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin: 5, value: 1 }, - }); - }); - - it("doesn't run when estopped", () => { - mockLocked = true; - runDemoLuaCode("on(5)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalled(); - }); - - it("runs off", () => { - runDemoLuaCode("off(5)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin: 5, value: 0 }, - }); - }); - - it("runs move_relative", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("move_relative(1, 0, 0)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 2, y: 2, z: 3 }, - }); - }); - - it("runs move_absolute", () => { - mockPosition = { x: 1, y: 2, z: 3 }; - runDemoLuaCode("move_absolute(1, 0, 0)"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 0, z: 0 }, - }); - }); - - it("runs emergency_lock", () => { - runDemoLuaCode("emergency_lock()"); - jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith({ - type: Actions.DEMO_SET_ESTOP, - payload: true, - }); - }); - - it("runs emergency_unlock", () => { - runDemoLuaCode("emergency_unlock()"); - jest.runAllTimers(); - expect(console.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(console.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).toHaveBeenCalledWith( - "Lua function \"foo.bar.baz\" is not implemented.", - TOAST_OPTIONS().error, - ); - }); -}); - -/** - * Lua functions available in the demo runner - * - * builtins/lib: - * [ y ] print - * [ y ] type - * [ y ] tostring - * [ y ] pairs - * [ y ] ipairs - * [ y ] os.time - * [ y ] math. - * [ y ] table. - * - * Other: - * [ y ] move_relative - * [ y ] round - * [ y ] angleRound - * [ y ] cropAmount - * [ y ] fwe - * [ y ] axis_overwrite - * [ y ] speed_overwrite - * - * FBOS: - * [ y ] variable (numeric only) - * [ ] auth_token - * [ y ] api (GET /api/points only) - * [ ] base64.decode - * [ ] base64.encode - * [ ] calibrate_camera - * [ ] check_position - * [ ] complete_job - * [ ] coordinate - * [ ] cs_eval - * [ ] current_hour - * [ ] current_minute - * [ ] current_month - * [ ] current_second - * [ ] detect_weeds - * [ y ] dispense - * [ y ] emergency_lock - * [ y ] emergency_unlock - * [ ] env - * [ ] fbos_version - * [ ] find_axis_length - * [ y ] find_home - * [ ] firmware_version - * [ y ] garden_size - * [ ] gcode - * [ y ] get_curve - * [ ] get_device - * [ ] get_fbos_config - * [ ] get_firmware_config - * [ ] get_job - * [ ] get_job_progress - * [ ] get_position - * [ y ] get_seed_tray_cell - * [ ] get_xyz - * [ ] get_tool - * [ y ] go_to_home - * [ y ] grid - * [ ] group - * [ ] http - * [ ] inspect - * [ ] json.decode - * [ ] json.encode - * [ ] measure_soil_height - * [ y ] mount_tool - * [ y ] dismount_tool - * [ y ] move_absolute - * [ y ] move - * [ ] new_sensor_reading - * [ y ] photo_grid - * [ ] read_pin - * [ ] read_status - * [ y ] rpc - * [ y ] sequence - * [ y ] send_message (info only) - * [ ] debug - * [ y ] toast (info only) - * [ ] safe_z - * [ ] set_job - * [ y ] set_job_progress - * [ ] set_pin_io_mode - * [ ] soft_stop - * [ ] soil_height - * [ ] sort - * [ ] take_photo_raw - * [ ] take_photo - * [ y ] toggle_pin - * [ ] uart.open - * [ ] uart.list - * [ ] update_device - * [ ] update_fbos_config - * [ ] update_firmware_config - * [ ] utc - * [ ] local_time - * [ ] to_unix - * [ y ] verify_tool - * [ y ] wait_ms - * [ y ] wait - * [ y ] water - * [ ] watch_pin - * [ y ] on - * [ y ] off - * [ y ] write_pin (digital only) - */ diff --git a/frontend/demo/lua_runner.ts b/frontend/demo/lua_runner.ts deleted file mode 100644 index 362aac3b8e..0000000000 --- a/frontend/demo/lua_runner.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { lua, lauxlib, lualib, to_jsstring, to_luastring } from "fengari-web"; -import { findSequenceById, selectAllPoints } from "../resources/selectors"; -import { ResourceIndex } from "../resources/interfaces"; -import { - ParameterApplication, PercentageProgress, SequenceBodyItem, - TaggedFirmwareConfig, Xyz, -} from "farmbot"; -import { info } from "../toast/toast"; -import { store } from "../redux/store"; -import { Actions } from "../constants"; -import { sortGroupBy } from "../point_groups/point_group_sort"; -import { validBotLocationData } from "../util/location"; -import { calculateAxialLengths } from "../controls/move/direction_axes_props"; -import { getFirmwareConfig } from "../resources/getters"; -import { LUA_HELPERS } from "./lua_helpers"; -import { TOAST_OPTIONS } from "../toast/constants"; - -const createRecursiveNotImplemented = ( - L: unknown, - actions: Action[], - path: string[], -) => { - lua.lua_newtable(L); - lua.lua_newtable(L); - lua.lua_pushjsfunction(L, () => { - const key: string = to_jsstring(lua.lua_tostring(L, 2)); - 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: "toast", - args: [ - `Lua function "${fullPath}" is not implemented.`, - "error", - ], - }); - lua.lua_pushboolean(L, false); - return 1; - }); - lua.lua_setfield(L, -2, to_luastring("__call")); - - lua.lua_setmetatable(L, -2); - return 1; -}; - -const runLua = - (luaCode: string, variables: ParameterApplication[]): Action[] => { - const actions: Action[] = []; - const L = lauxlib.luaL_newstate(); // stack: [] - - lua.lua_newtable(L); // stack: [env] - - lauxlib.luaL_requiref(L, to_luastring("_G"), lualib.luaopen_base, 1); - - lua.lua_getfield(L, -1, to_luastring("type")); - lua.lua_setfield(L, -3, to_luastring("type")); - - lua.lua_getfield(L, -1, to_luastring("tostring")); - lua.lua_setfield(L, -3, to_luastring("tostring")); - - lua.lua_getfield(L, -1, to_luastring("pairs")); - lua.lua_setfield(L, -3, to_luastring("pairs")); - - lua.lua_getfield(L, -1, to_luastring("ipairs")); - lua.lua_setfield(L, -3, 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, -2, to_luastring("math")); - - lauxlib.luaL_requiref(L, to_luastring("table"), lualib.luaopen_table, 1); - lua.lua_setfield(L, -2, to_luastring("table")); - - 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 += to_jsstring(lua.lua_tostring(L, i)); - } else if (lua.lua_isboolean(L, i)) { - output += lua.lua_toboolean(L, i) ? "true" : "false"; - } else { - output += ""; - } - } - actions.push({ type: "print", args: [output] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("print")); - - lua.lua_pushjsfunction(L, () => { - const variableName = to_jsstring(lua.lua_tostring(L, 1)); - const n = variables - .filter(variable => variable.args.label === variableName) - .map(variable => variable.args.data_value)[0]; - if (n?.kind === "numeric") { - lua.lua_pushnumber(L, n.args.number); - } else { - actions.push({ - type: "toast", - args: [ - "Variables of type other than numeric not implemented.", - "error", - ], - }); - lua.lua_pushnil(L); - } - return 1; - }); - lua.lua_setfield(L, -2, to_luastring("variable")); - - lua.lua_newtable(L); // stack: [env, os] - - lua.lua_pushjsfunction(L, () => { - const now = Math.floor(Date.now() / 1000); - lua.lua_pushnumber(L, now); - return 1; - }); - lua.lua_setfield(L, -2, to_luastring("time")); // stack: [env, os] - lua.lua_setfield(L, -2, to_luastring("os")); // stack: [env] - - lua.lua_pushjsfunction(L, () => { - lua.lua_getfield(L, 1, to_luastring("method")); - const method = lua.lua_isnil(L, -1) - ? "GET" - : to_jsstring(lua.lua_tostring(L, -1)); - lua.lua_pop(L, 1); - - lua.lua_getfield(L, 1, to_luastring("url")); - const url = to_jsstring(lua.lua_tostring(L, -1)); - lua.lua_pop(L, 1); - - if (!(method == "GET" && url == "/api/points")) { - actions.push({ - type: "toast", - args: [ - "API calls other than GET /api/points not implemented.", - "error", - ], - }); - lua.lua_pushboolean(L, false); - return 1; - } - - const points = selectAllPoints(store.getState().resources.index); - const results = sortGroupBy("yx_alternating", points).map(p => p.body); - lua.lua_newtable(L); - results.forEach((result, i) => { - lua.lua_newtable(L); - Object.entries(result).forEach(([k, v]) => { - lua.lua_pushstring(L, to_luastring(k)); - - if (typeof v === "string") { - lua.lua_pushstring(L, to_luastring(v)); - } else if (typeof v === "number") { - lua.lua_pushnumber(L, v); - } else if (typeof v === "boolean") { - lua.lua_pushboolean(L, v); - } else { - lua.lua_pushnil(L); - } - lua.lua_settable(L, -3); - }); - lua.lua_rawseti(L, -2, i + 1); - }); - return 1; - }); - lua.lua_setfield(L, -2, to_luastring("api")); - - lua.lua_pushjsfunction(L, () => { - const n = lua.lua_gettop(L); - const args = []; - for (let i = 1; i <= n; i++) { - args.push(to_jsstring(lua.lua_tostring(L, i))); - } - actions.push({ type: "send_message", args: args }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("send_message")); - - lua.lua_pushjsfunction(L, () => { - const msg = to_jsstring(lua.lua_tostring(L, 1)); - const type = lua.lua_gettop(L) >= 2 - ? to_jsstring(lua.lua_tostring(L, 2)) - : "info"; - actions.push({ type: "toast", args: [msg, type] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("toast")); - - lua.lua_pushjsfunction(L, () => { - const msg = to_jsstring(lua.lua_tostring(L, 1)); - actions.push({ type: "toast", args: [msg, "debug"] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("debug")); - - lua.lua_pushjsfunction(L, () => { - const jobName = to_jsstring(lua.lua_tostring(L, 1)); - - lua.lua_getfield(L, 2, to_luastring("percent")); - const percent = lua.lua_tonumber(L, -1); - lua.lua_pop(L, 1); - - lua.lua_getfield(L, 2, to_luastring("status")); - const status = to_jsstring(lua.lua_tostring(L, -1)); - lua.lua_pop(L, 1); - - lua.lua_getfield(L, 2, to_luastring("time")); - const time = lua.lua_tonumber(L, -1); - lua.lua_pop(L, 1); - - actions.push({ - type: "set_job_progress", - args: [jobName, percent, status, time], - }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("set_job_progress")); - - lua.lua_pushjsfunction(L, () => { - const n = lua.lua_gettop(L); - const args = []; - for (let i = 1; i <= n; i++) { - args.push(lua.lua_tonumber(L, i)); - } - actions.push({ type: "move_absolute", args: args }); - return 0; - }); - lua.lua_setfield(L, -2, 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(lua.lua_tonumber(L, i)); - } - actions.push({ type: "move_relative", args: args }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("move_relative")); - - lua.lua_pushjsfunction(L, () => { - const axis = to_jsstring(lua.lua_tostring(L, -1)); - actions.push({ type: "find_home", args: [axis] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("find_home")); - - lua.lua_pushjsfunction(L, () => { - const axis = to_jsstring(lua.lua_tostring(L, -1)); - actions.push({ type: "go_to_home", args: [axis] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("go_to_home")); - - lua.lua_pushjsfunction(L, () => { - const ms = lua.lua_tonumber(L, 1); - actions.push({ type: "wait_ms", args: [ms] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("wait_ms")); - - lua.lua_pushjsfunction(L, () => { - const pin = lua.lua_tonumber(L, 1); - const value = lua.lua_tonumber(L, 3); - actions.push({ type: "write_pin", args: [pin, value] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("write_pin")); - - lua.lua_pushjsfunction(L, () => { - const pin = lua.lua_tonumber(L, 1); - actions.push({ type: "toggle_pin", args: [pin] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("toggle_pin")); - - lua.lua_pushjsfunction(L, () => { - const pin = lua.lua_tonumber(L, 1); - actions.push({ type: "on", args: [pin] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("on")); - - lua.lua_pushjsfunction(L, () => { - const pin = lua.lua_tonumber(L, 1); - actions.push({ type: "off", args: [pin] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("off")); - - lua.lua_pushjsfunction(L, () => { - const fwConfig = getFirmwareConfig(store.getState().resources.index); - const firmwareSettings = (fwConfig as TaggedFirmwareConfig).body; - const { x, y, z } = calculateAxialLengths({ firmwareSettings }); - lua.lua_newtable(L); - lua.lua_pushstring(L, to_luastring("x")); - lua.lua_pushnumber(L, x); - lua.lua_settable(L, -3); - lua.lua_pushstring(L, to_luastring("y")); - lua.lua_pushnumber(L, y); - lua.lua_settable(L, -3); - lua.lua_pushstring(L, to_luastring("z")); - lua.lua_pushnumber(L, z); - lua.lua_settable(L, -3); - return 1; - }); - lua.lua_setfield(L, -2, 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, -2, to_luastring("emergency_lock")); - - lua.lua_pushjsfunction(L, () => { - actions.push({ type: "emergency_unlock", args: [] }); - return 0; - }); - lua.lua_setfield(L, -2, to_luastring("emergency_unlock")); - - lua.lua_newtable(L); - lua.lua_pushjsfunction(L, () => { - const key: string = to_jsstring(lua.lua_tostring(L, 2)); - 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 error = to_jsstring(lua.lua_tostring(L, -1)); - console.error("Lua load error:", error); - 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 errorVal = lua.lua_tostring(L, -1); - const error = to_jsstring(errorVal); - console.error("Lua call error:", error); - return []; - } - return actions; - }; - -export const runDemoLuaCode = (luaCode: string) => { - const actions = runLua(luaCode, []); - runActions(actions); -}; - -export const runDemoSequence = ( - resources: ResourceIndex, - sequenceId: number, - variables: ParameterApplication[] | undefined, -) => { - const sequence = findSequenceById(resources, sequenceId); - const actions: Action[] = []; - (sequence.body.body as SequenceBodyItem[]).map(step => { - if (step.kind === "lua") { - const stepActions = runLua(step.args.lua, variables || []); - actions.push(...stepActions); - } - }); - runActions(actions); -}; - -interface Action { - type: "move_absolute" - | "move_relative" - | "toggle_pin" - | "on" - | "off" - | "emergency_lock" - | "emergency_unlock" - | "find_home" - | "go_to_home" - | "toast" - | "send_message" - | "print" - | "wait_ms" - | "write_pin" - | "set_job_progress"; - args: (number | string)[]; -} - -const almostEqual = (a: Record, b: Record) => { - 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: Record, - target: Record, -): Record[] => { - 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 = Math.floor(length / 100); - const chunks: Record[] = []; - for (let i = 0; i <= steps; i++) { - const step = { - x: current.x + direction.x * 100 * i, - y: current.y + direction.y * 100 * i, - z: current.z + direction.z * 100 * i, - }; - chunks.push(step); - } - if (!almostEqual(chunks[chunks.length - 1], target)) { - chunks.push(target); - } - return chunks; -}; - -const expandActions = (actions: Action[]): Action[] => { - const expanded: Action[] = []; - const { position } = validBotLocationData( - store.getState().bot.hardware.location_data); - const current = { - x: position.x as number, - y: position.y as number, - z: position.z as number, - }; - const addPosition = (position: Record) => { - expanded.push({ - type: "wait_ms", - args: [500], - }); - expanded.push({ - type: "move_absolute", - args: [position.x, position.y, position.z], - }); - }; - const setCurrent = (position: Record) => { - current.x = position.x; - current.y = position.y; - current.z = position.z; - }; - actions.map(action => { - switch (action.type) { - case "move_absolute": - const target = { - x: action.args[0] as number, - y: action.args[1] as number, - z: action.args[2] as number, - }; - movementChunks(current, target).map(addPosition); - setCurrent(target); - break; - case "move_relative": - const moveRelativeTarget = { - 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).map(addPosition); - setCurrent(moveRelativeTarget); - break; - case "find_home": - case "go_to_home": - const axis = action.args[0] as string; - const homeTarget = { - x: ["all", "x"].includes(axis) ? 0 : current.x, - y: ["all", "y"].includes(axis) ? 0 : current.y, - z: ["all", "z"].includes(axis) ? 0 : current.z, - }; - movementChunks(current, homeTarget).map(addPosition); - setCurrent(homeTarget); - break; - case "on": - case "off": - const pin = action.args[0] as number; - const value = action.type === "on" ? 1 : 0; - expanded.push({ type: "write_pin", args: [pin, value] }); - break; - case "toast": - const msg = "" + action.args[0]; - const type = "" + action.args[1]; - expanded.push({ - type: "send_message", - args: [type, msg], - }); - break; - default: - expanded.push(action); - break; - } - }); - return expanded; -}; - -const pending = new Set>(); - -const runActions = (actions: Action[]) => { - let delay = 0; - expandActions(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") { - 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]; - return () => { - info(msg, TOAST_OPTIONS()[type]); - }; - case "print": - return () => { - console.log(action.args[0]); - }; - case "emergency_lock": - return () => { - pending.forEach(clearTimeout); - pending.clear(); - store.dispatch({ - type: Actions.DEMO_SET_ESTOP, - payload: true, - }); - }; - case "emergency_unlock": - return () => { - store.dispatch({ - type: Actions.DEMO_SET_ESTOP, - payload: false, - }); - }; - case "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 value = action.args[1] as number; - return () => { - store.dispatch({ - type: Actions.DEMO_WRITE_PIN, - payload: { pin, 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, - status: status as "working", - type: "", - 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], - }); - }; - } - }; - const func = getFunc(); - func && pending.add(setTimeout(func, delay)); - }); -}; 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..47eb296fb5 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -0,0 +1,14 @@ +import { TOAST_OPTIONS } from "../../../toast/constants"; +import { info } from "../../../toast/toast"; +import { runActions } from "../actions"; + +describe("runActions()", () => { + it("runs actions", () => { + jest.useFakeTimers(); + runActions([ + { type: "send_message", args: ["info", "Hello, world!", "toast"] }, + ]); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith("Hello, world!", TOAST_OPTIONS().info); + }); +}); 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..732080bc9c --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -0,0 +1,932 @@ +import { + buildResourceIndex, + fakeDevice, +} from "../../../__test_support__/resource_index_builder"; +import { + fakeFirmwareConfig, + fakePoint, + fakeSequence, fakeTool, + fakeToolSlot, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +let mockPosition = { x: 0, y: 0, z: 0 }; +let mockLocked = false; +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ + resources: mockResources, + bot: { + hardware: { + location_data: { position: mockPosition }, + informational_settings: { locked: mockLocked }, + }, + }, + }), + }, +})); + +jest.mock("../../../api/crud", () => ({ + edit: jest.fn(), + save: jest.fn(), +})); + +import { ParameterApplication, TaggedSequence } from "farmbot"; +import { Actions } from "../../../constants"; +import { store } from "../../../redux/store"; +import { info } from "../../../toast/toast"; +import { csToLua, runDemoLuaCode, runDemoSequence } from ".."; +import { TOAST_OPTIONS } from "../../../toast/constants"; +import { edit, save } from "../../../api/crud"; + +describe("runDemoSequence()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + console.error = 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(console.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(console.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(console.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(console.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(console.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(console.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(console.error).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("undefined"); + }); + + 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).toHaveBeenCalledWith( + "Variable \"Other\" of type identifier not implemented.", + TOAST_OPTIONS().error); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(console.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(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); + expect(console.log).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; + runDemoSequence(ri, sequence.body.id, undefined); + jest.runAllTimers(); + expect(info).toHaveBeenCalledWith( + "Variable \"Number\" of type undefined not implemented.", + TOAST_OPTIONS().error); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(console.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).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.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).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.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).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + "Lua call error:", + expect.stringContaining("attempt to perform arithmetic"), + ); + }); +}); + +describe("runDemoLuaCode()", () => { + beforeEach(() => { + localStorage.setItem("myBotIs", "online"); + console.log = jest.fn(); + console.error = jest.fn(); + jest.useFakeTimers(); + mockLocked = false; + }); + + it("runs print", () => { + runDemoLuaCode("print(\"Hello, world!\")"); + jest.runAllTimers(); + expect(console.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(console.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]); + runDemoLuaCode( + "print(garden_size().x)\n" + + "print(garden_size().y)\n" + + "print(garden_size().z)"); + jest.runAllTimers(); + expect(console.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(console.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(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + expect(info).not.toHaveBeenCalled(); + }); + + 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(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("table 1"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs api: other", () => { + mockResources = buildResourceIndex([]); + runDemoLuaCode(` + local data = api{ + method = "GET", + url = "/api/other" + } + print(data) + `); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("false"); + expect(info).toHaveBeenCalledWith( + "API call GET /api/other not implemented.", + TOAST_OPTIONS().error, + ); + }); + + it("runs cs_eval", () => { + mockPosition = { 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(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 3 }, + }); + }); + + it("runs cs_eval: no body", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("cs_eval{}"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("runs toast", () => { + runDemoLuaCode("toast(\"test\", \"info\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs toast: default", () => { + runDemoLuaCode("toast(\"test\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + + it("runs debug", () => { + runDemoLuaCode("debug(\"test\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().debug); + }); + + it("runs send_message", () => { + runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); + jest.runAllTimers(); + expect(console.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(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 50, + status: "working", + type: "", + 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(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_JOB_PROGRESS, + payload: ["job", { + unit: "percent", + percent: 100, + status: "Complete", + type: "", + file_type: "", + updated_at: expect.any(Number), + time: undefined, + }], + }); + }); + + it("runs find_home: all", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("find_home(\"all\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"all\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"x\")"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("go_to_home(\"y\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 3 }, + }); + }); + + it("runs toggle_pin", () => { + runDemoLuaCode("toggle_pin(5)"); + jest.runAllTimers(); + expect(console.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(console.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(console.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(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it("runs off", () => { + runDemoLuaCode("off(5)"); + jest.runAllTimers(); + expect(console.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(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + 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(console.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(console.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(console.error).not.toHaveBeenCalled(); + expect(console.log).not.toHaveBeenCalled(); + 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(console.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(console.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(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("0"); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs move_relative", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_relative(1, 0, 0)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 2, y: 2, z: 3 }, + }); + }); + + it("runs move_relative: zero", () => { + mockPosition = { x: 0, y: 0, z: 0 }; + runDemoLuaCode("move_relative(0, 0, 0)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: 0 }, + }); + }); + + it("runs move_absolute", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_absolute(1, 0, 0)"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_absolute{ x = 1, y = 0, z = 0 }"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 0 }, + }); + }); + + it("runs move: y", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move{ y = 1 }"); + jest.runAllTimers(); + expect(console.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", () => { + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move{ x = 0, z = 0 }"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 0 }, + }); + }); + + it("runs emergency_lock", () => { + runDemoLuaCode("emergency_lock()"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + }); + + it("runs emergency_unlock", () => { + runDemoLuaCode("emergency_unlock()"); + jest.runAllTimers(); + expect(console.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(console.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).toHaveBeenCalledWith( + "Lua function \"foo.bar.baz\" is not implemented.", + TOAST_OPTIONS().error, + ); + }); +}); + +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 (numeric/text only) + * [ ] auth_token + * [ y ] api (GET /api/points only) + * [ ] base64.decode + * [ ] base64.encode + * [ ] calibrate_camera + * [ ] check_position + * [ ] complete_job + * [ ] coordinate + * [ y ] cs_eval + * [ y ] current_hour + * [ y ] current_minute + * [ y ] current_month + * [ y ] current_second + * [ ] detect_weeds + * [ y ] dispense + * [ y ] emergency_lock + * [ y ] emergency_unlock + * [ ] env + * [ ] fbos_version + * [ ] find_axis_length + * [ y ] find_home + * [ ] firmware_version + * [ y ] garden_size + * [ ] gcode + * [ y ] get_curve + * [ y ] get_device + * [ ] get_fbos_config + * [ ] get_firmware_config + * [ ] get_job + * [ ] get_job_progress + * [ ] get_position + * [ y ] get_seed_tray_cell + * [ ] get_xyz + * [ ] get_tool + * [ y ] go_to_home + * [ y ] grid + * [ ] group + * [ ] http + * [ ] inspect + * [ ] json.decode + * [ ] json.encode + * [ ] 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 (info only) + * [ y ] debug + * [ y ] toast (info only) + * [ y ] safe_z + * [ ] set_job + * [ y ] set_job_progress + * [ ] set_pin_io_mode + * [ ] soft_stop + * [ ] soil_height + * [ ] sort + * [ ] take_photo_raw + * [ ] take_photo + * [ y ] toggle_pin + * [ ] uart.open + * [ ] uart.list + * [ y ] update_device + * [ ] update_fbos_config + * [ ] update_firmware_config + * [ y ] utc + * [ ] local_time + * [ ] to_unix + * [ y ] verify_tool + * [ y ] wait_ms + * [ y ] wait + * [ y ] water + * [ ] watch_pin + * [ y ] on + * [ y ] off + * [ y ] write_pin (digital only) + */ 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..f39a1fca52 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/run_test.ts @@ -0,0 +1,18 @@ +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(code, [])).toEqual([ + { type: "move_absolute", args: [1, 2, 3] }, + { type: "wait_ms", args: [1000] }, + { type: "go_to_home", args: ["all"] }, + { type: "move", args: [undefined, 1, undefined] }, + ]); + }); +}); 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..fa45234ffb --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/util_test.ts @@ -0,0 +1,133 @@ +import { csToLua } from "../util"; +import { + EmergencyLock, EmergencyUnlock, FindHome, Home, Lua, Move, MoveAbsolute, + MoveRelative, SendMessage, SequenceBodyItem, 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: 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{y=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: 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\")"); + }); +}); diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts new file mode 100644 index 0000000000..1b6a844898 --- /dev/null +++ b/frontend/demo/lua_runner/actions.ts @@ -0,0 +1,229 @@ +import { PercentageProgress, Xyz } from "farmbot"; +import { info } from "../../toast/toast"; +import { store } from "../../redux/store"; +import { Actions } from "../../constants"; +import { validBotLocationData } from "../../util/location"; +import { TOAST_OPTIONS } from "../../toast/constants"; +import { Action } from "./interfaces"; +import { edit, save } from "../../api/crud"; +import { getDeviceAccountSettings } from "../../resources/selectors"; +import { UnknownAction } from "redux"; + +const almostEqual = (a: Record, b: Record) => { + 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: Record, + target: Record, +): Record[] => { + 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 = Math.floor(length / 100); + const chunks: Record[] = []; + for (let i = 0; i <= steps; i++) { + const step = { + x: current.x + direction.x * 100 * i, + y: current.y + direction.y * 100 * i, + z: current.z + direction.z * 100 * i, + }; + chunks.push(step); + } + if (!almostEqual(chunks[chunks.length - 1], target)) { + chunks.push(target); + } + return chunks; +}; + +const expandActions = (actions: Action[]): Action[] => { + const expanded: Action[] = []; + const { position } = validBotLocationData( + store.getState().bot.hardware.location_data); + const current = { + x: position.x as number, + y: position.y as number, + z: position.z as number, + }; + const addPosition = (position: Record) => { + expanded.push({ + type: "wait_ms", + args: [500], + }); + expanded.push({ + type: "move_absolute", + args: [position.x, position.y, position.z], + }); + }; + const setCurrent = (position: Record) => { + current.x = position.x; + current.y = position.y; + current.z = position.z; + }; + actions.map(action => { + switch (action.type) { + case "move_absolute": + const target = { + x: action.args[0] as number, + y: action.args[1] as number, + z: action.args[2] as number, + }; + movementChunks(current, target).map(addPosition); + setCurrent(target); + break; + case "move_relative": + const moveRelativeTarget = { + 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).map(addPosition); + setCurrent(moveRelativeTarget); + break; + case "move": + const moveTarget = { + x: (action.args[0] as number | undefined) ?? current.x, + y: (action.args[1] as number | undefined) ?? current.y, + z: (action.args[2] as number | undefined) ?? current.z, + }; + movementChunks(current, moveTarget).map(addPosition); + setCurrent(moveTarget); + 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).map(addPosition); + setCurrent(homeTarget); + }); + break; + default: + expanded.push(action); + break; + } + }); + return expanded; +}; + +const pending = new Set>(); + +export const runActions = (actions: Action[]) => { + let delay = 0; + expandActions(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") { + 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]; + return () => { + info(msg, TOAST_OPTIONS()[type]); + }; + case "print": + return () => { + console.log(action.args[0]); + }; + case "emergency_lock": + return () => { + pending.forEach(clearTimeout); + pending.clear(); + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: true, + }); + }; + case "emergency_unlock": + return () => { + store.dispatch({ + type: Actions.DEMO_SET_ESTOP, + payload: false, + }); + }; + case "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, + status: status as "working", + type: "", + 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 "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(); + func && pending.add(setTimeout(func, delay)); + }); +}; diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts new file mode 100644 index 0000000000..7cd4b8e2b3 --- /dev/null +++ b/frontend/demo/lua_runner/index.ts @@ -0,0 +1,29 @@ +import { findSequenceById } from "../../resources/selectors"; +import { ResourceIndex } from "../../resources/interfaces"; +import { ParameterApplication, SequenceBodyItem } from "farmbot"; +import { runLua } from "./run"; +import { runActions } from "./actions"; +import { Action } from "./interfaces"; +import { csToLua } from "./util"; + +export const runDemoLuaCode = (luaCode: string) => { + const actions = runLua(luaCode, []); + runActions(actions); +}; + +export const runDemoSequence = ( + resources: ResourceIndex, + sequenceId: number, + variables: ParameterApplication[] | undefined, +) => { + const sequence = findSequenceById(resources, sequenceId); + const actions: Action[] = []; + (sequence.body.body as SequenceBodyItem[]).map(step => { + const lua = step.kind === "lua" ? step.args.lua : csToLua(step); + const stepActions = runLua(lua, variables || []); + actions.push(...stepActions); + }); + runActions(actions); +}; + +export { csToLua }; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts new file mode 100644 index 0000000000..f722801276 --- /dev/null +++ b/frontend/demo/lua_runner/interfaces.ts @@ -0,0 +1,18 @@ +export interface Action { + type: + | "move_absolute" + | "move_relative" + | "move" + | "toggle_pin" + | "emergency_lock" + | "emergency_unlock" + | "find_home" + | "go_to_home" + | "send_message" + | "update_device" + | "print" + | "wait_ms" + | "write_pin" + | "set_job_progress"; + args: (number | string | undefined)[]; +} diff --git a/frontend/demo/lua_helpers.ts b/frontend/demo/lua_runner/lua.ts similarity index 93% rename from frontend/demo/lua_helpers.ts rename to frontend/demo/lua_runner/lua.ts index 0d521e9472..937c217b05 100644 --- a/frontend/demo/lua_helpers.ts +++ b/frontend/demo/lua_runner/lua.ts @@ -1,5 +1,6 @@ /* eslint-disable max-len */ -export const LUA_HELPERS = ` + +const FROM_FBOS = ` function grid(params) local x_point_count = params.grid_points.x local y_point_count = params.grid_points.y @@ -280,7 +281,7 @@ function dismount_tool() move{z = slot.z + 50} -- Check verification pin - if read_pin(63) == 0 then + if read_pin(63) == 0 and false then job(90, "Failed") toast("Tool dismounting failed - there is still an electrical connection between UTM pins B and C.", "error") return @@ -567,7 +568,7 @@ function mount_tool(input) end -- Check verification pin - if read_pin(63) == 1 then + if read_pin(63) == 1 and false then job(80, "Failed") toast("Tool mounting failed - no electrical connection between UTM pins B and C.", "error") return @@ -598,7 +599,7 @@ function speed_overwrite(axis, num) } end -function move(input) +function re_move(input) cs_eval({ kind = "rpc_request", args = {label = "move_cmd_lua", priority = 500}, @@ -733,3 +734,83 @@ function water(plant, 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 +`; + +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..3cf0789c43 --- /dev/null +++ b/frontend/demo/lua_runner/run.ts @@ -0,0 +1,365 @@ +import { lua, lauxlib, lualib, to_luastring } from "fengari-web"; +import { + getDeviceAccountSettings, + selectAllPoints, selectAllTools, selectAllToolSlotPointers, +} from "../../resources/selectors"; +import { + ParameterApplication, RpcRequest, TaggedFirmwareConfig, + Xyz, +} from "farmbot"; +import { store } from "../../redux/store"; +import { sortGroupBy } from "../../point_groups/point_group_sort"; +import { calculateAxialLengths } from "../../controls/move/direction_axes_props"; +import { getFirmwareConfig } from "../../resources/getters"; +import { LUA_HELPERS } from "./lua"; +import { createRecursiveNotImplemented, csToLua, jsToLua, luaToJs } from "./util"; +import { Action } from "./interfaces"; +import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; + +export const runLua = + (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 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, point); + break; + case "tool": + const slot = selectAllToolSlotPointers(store.getState().resources.index) + .find(ts => ts.body.tool_id === n.args.tool_id)?.body; + jsToLua(L, slot); + break; + default: + actions.push({ + type: "send_message", + args: [ + "error", + `Variable "${variableName}" of type ${n?.kind} not implemented.`, + "toast", + ], + }); + 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")); + 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 method = lua.lua_isnil(L, -1) + ? "GET" + : luaToJs(L, -1) as string; + 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 (method == "GET" && url == "/api/points") { + const points = selectAllPoints(store.getState().resources.index); + const results = sortGroupBy("yx_alternating", points).map(p => p.body); + jsToLua(L, results); + return 1; + } else if (method == "GET" && url == "/api/tools") { + const results = selectAllTools(store.getState().resources.index) + .map(p => p.body); + jsToLua(L, results); + return 1; + } else { + actions.push({ + type: "send_message", + args: [ + "error", + `API call ${method} ${url} not implemented.`, + "toast", + ], + }); + jsToLua(L, false); + return 1; + } + }); + lua.lua_setfield(L, envIndex, to_luastring("api")); + + lua.lua_pushjsfunction(L, () => { + const cmd = (luaToJs(L, 1) as RpcRequest).body?.[0]; + if (!cmd) { return 0; } + const luaActions = runLua(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); + } + 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 args = []; + const n = lua.lua_gettop(L); + if (n == 1) { + const params = luaToJs(L, 1) as Record; + ["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 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, 0); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("safe_z")); + + lua.lua_pushjsfunction(L, () => { + const args = luaToJs(L, 1) as Partial>; + actions.push({ type: "move", args: [args.x, args.y, args.z] }); + return 0; + }); + lua.lua_setfield(L, envIndex, to_luastring("move")); + + 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 fwConfig = getFirmwareConfig(store.getState().resources.index); + const firmwareSettings = (fwConfig as TaggedFirmwareConfig).body; + const lengths = calculateAxialLengths({ firmwareSettings }); + 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 error = luaToJs(L, -1) as string; + console.error("Lua load error:", error); + 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 error = luaToJs(L, -1) as string; + console.error("Lua call error:", error); + return []; + } + return actions; + }; diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts new file mode 100644 index 0000000000..1cd97e585f --- /dev/null +++ b/frontend/demo/lua_runner/util.ts @@ -0,0 +1,151 @@ +import { lua, to_jsstring, to_luastring } from "fengari-web"; +import { Action } from "./interfaces"; +import { RpcRequestBodyItem } from "farmbot"; + +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.`, + "toast", + ], + }); + 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 isSequentialArray = + keyVals.every(([k], i) => typeof k === "number" && k === i + 1); + if (isSequentialArray) { + return keyVals.map(([, v]) => v); + } 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 "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 values = (body || []) + .filter(part => part.kind == "axis_overwrite") + .map(axisOverwrite => { + const { axis, axis_operand } = axisOverwrite.args; + if (axis_operand.kind == "numeric") { + return `${axis}=${axis_operand.args.number}`; + } + }) + .join(", "); + return `move{${values}}`; + case "write_pin": + const mode = args.pin_mode ? "analog" : "digital"; + return `write_pin(${args.pin_number}, "${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")`; + } +}; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index e592e17683..cb534bc76b 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -45,6 +45,7 @@ jest.mock("../../redux/store", () => ({ jest.mock("../../demo/lua_runner", () => ({ runDemoSequence: jest.fn(), runDemoLuaCode: jest.fn(), + csToLua: jest.fn(), })); import * as actions from "../actions"; @@ -57,9 +58,9 @@ 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, Farmbot } from "farmbot"; import { Path } from "../../internal_urls"; -import { runDemoLuaCode, runDemoSequence } from "../../demo/lua_runner"; +import { csToLua, runDemoLuaCode, runDemoSequence } from "../../demo/lua_runner"; const replaceDeviceWith = async (d: DeepPartial, cb: Function) => { jest.clearAllMocks(); @@ -69,6 +70,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({ @@ -77,6 +82,14 @@ describe("sendRPC()", () => { body: [{ kind: "sync", args: {} }], }); }); + + it("calls sendRPC on demo accounts", async () => { + localStorage.setItem("myBotIs", "online"); + const cmd: EmergencyLock = { kind: "emergency_lock", args: {} }; + await actions.sendRPC(cmd); + expect(mockDevice.current.send).not.toHaveBeenCalled(); + expect(csToLua).toHaveBeenCalledWith(cmd); + }); }); describe("readStatus()", () => { @@ -148,7 +161,7 @@ describe("flashFirmware()", () => { }); describe("emergencyLock() / emergencyUnlock", () => { - beforeEach(() => { + afterEach(() => { localStorage.removeItem("myBotIs"); window.confirm = () => false; }); diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index e32cc3d56b..d31c32c437 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -181,16 +181,26 @@ describe("botReducer", () => { expect(r.hardware.pins).toEqual({ 13: { value: 0, mode: 0 } }); }); - it("writes demo pin", () => { + it("writes demo pin: digital", () => { const state = initialState(); const action = { type: Actions.DEMO_WRITE_PIN, - payload: { pin: 13, value: 1 }, + 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 = { @@ -231,5 +241,4 @@ describe("botReducer", () => { const r = botReducer(state, action); expect(r.hardware.informational_settings.locked).toEqual(false); }); - }); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 6afb0afaa8..404ebeb008 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -35,7 +35,7 @@ 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 } from "../demo/lua_runner"; +import { runDemoLuaCode, runDemoSequence, csToLua } from "../demo/lua_runner"; const ON = 1, OFF = 0; export type ConfigKey = keyof McuParams; @@ -83,7 +83,10 @@ const maybeAlertLocked = () => /** Send RPC. */ export function sendRPC(command: RpcRequestBodyItem) { - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode(csToLua(command)); + return; + } getDevice() .send(rpcRequest([command])) .then(maybeNoop, commandErr()); diff --git a/frontend/devices/reducer.ts b/frontend/devices/reducer.ts index 7b9377b0a5..67f8b934f9 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -137,10 +137,12 @@ export const botReducer = generateReducer(initialState()) pin.value = Number(!pin.value); return s; }) - .add<{ pin: number, value: number }>(Actions.DEMO_WRITE_PIN, (s, { payload }) => { - s.hardware.pins[payload.pin] = { mode: 0, value: payload.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; From 2870de2b52d2c19817a7355287883d09ba364c8a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 11 Jul 2025 10:34:05 -0700 Subject: [PATCH 11/54] upgrade deps --- Gemfile.lock | 8 ++++---- package.json | 12 ++++++------ spec/controllers/dashboard_spec.rb | 24 ++++++++++++------------ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f6dfac13b1..87171928d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,9 +118,9 @@ GEM factory_bot_rails (6.5.0) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.5.1) + faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.1) + faraday (2.13.2) faraday-net_http (>= 2.0, < 3.5) json logger @@ -140,7 +140,7 @@ GEM retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.24.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.53.0) + google-apis-storage_v1 (0.54.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -207,7 +207,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) - multi_json (1.15.0) + multi_json (1.16.0) mutations (0.9.1) activesupport mutex_m (0.3.0) diff --git a/package.json b/package.json index f981f223c3..c35c5ffecb 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,14 @@ "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", "@rollbar/react": "0.12.1", - "@types/lodash": "4.17.19", + "@types/lodash": "4.17.20", "@types/markdown-it": "14.1.2", - "@types/node": "24.0.7", + "@types/node": "24.0.13", "@types/promise-timeout": "1.3.3", "@types/react": "19.1.8", "@types/react-color": "3.0.13", "@types/react-dom": "19.1.6", - "@types/three": "0.177.0", + "@types/three": "0.178.1", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", "axios": "1.10.0", @@ -64,13 +64,13 @@ "farmbot": "15.8.11", "fengari": "0.1.4", "fengari-web": "0.1.4", - "i18next": "25.3.0", + "i18next": "25.3.2", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.52.2", - "mqtt": "5.13.1", + "mqtt": "5.13.2", "npm": "11.4.2", "parcel": "2.15.4", "process": "0.11.10", @@ -85,7 +85,7 @@ "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", - "rollbar": "3.0.0-alpha.2", + "rollbar": "2.26.4", "suncalc": "1.9.0", "takeme": "0.12.0", "three": "0.178.0", diff --git a/spec/controllers/dashboard_spec.rb b/spec/controllers/dashboard_spec.rb index 67715bd394..4b56ca9618 100644 --- a/spec/controllers/dashboard_spec.rb +++ b/spec/controllers/dashboard_spec.rb @@ -34,12 +34,12 @@ it "receives CSP violation reports: JSON parse error" do malformed_json = "{ this is not valid json" expect(Rollbar).to receive(:info).with( - "CSP Violation Report", - hash_including( - error: "CSP report parse error", - exception: kind_of(String), - raw: malformed_json - ) + "CSP Violation Report", + hash_including( + error: "CSP report parse error", + exception: kind_of(String), + raw: malformed_json + ) ) post :csp_reports, body: malformed_json expect(response).to have_http_status(:no_content) @@ -47,12 +47,12 @@ it "receives CSP violation reports: empty body" do expect(Rollbar).to receive(:info).with( - "CSP Violation Report", - hash_including( - error: "CSP report parse error", - exception: kind_of(String), - raw: "" - ) + "CSP Violation Report", + hash_including( + error: "CSP report parse error", + exception: kind_of(String), + raw: "" + ) ) post :csp_reports, body: "" expect(response).to have_http_status(:no_content) From e6d5ea7bf46a94ee540bd17ce32efc1969153884 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 12 Jul 2025 00:42:55 -0700 Subject: [PATCH 12/54] improve 3D and demo account tool handling --- app/mutations/devices/seeders/constants.rb | 2 +- .../devices/seeders/demo_account_seeder.rb | 5 + frontend/__test_support__/three_d_mocks.tsx | 8 +- .../demo/lua_runner/__tests__/index_test.ts | 120 ++++++++- .../demo/lua_runner/__tests__/subs_test.ts | 54 ++++ .../demo/lua_runner/__tests__/util_test.ts | 51 ++++ frontend/demo/lua_runner/actions.ts | 49 ++-- frontend/demo/lua_runner/interfaces.ts | 4 + frontend/demo/lua_runner/lua.ts | 4 +- frontend/demo/lua_runner/run.ts | 60 ++++- frontend/demo/lua_runner/stubs.ts | 46 ++++ frontend/demo/lua_runner/util.ts | 14 +- frontend/devices/__tests__/actions_test.ts | 171 ++++++------ frontend/devices/actions.ts | 14 +- frontend/devices/reducer.ts | 1 + .../__tests__/guess_timezone_test.ts | 21 ++ frontend/devices/timezones/guess_timezone.ts | 4 +- .../three_d_garden/bot/__tests__/bot_test.tsx | 4 +- frontend/three_d_garden/bot/bot.tsx | 12 +- .../__tests__/cable_carriers_test.tsx | 28 +- .../bot/components/cable_carriers.tsx | 244 +++++++++--------- .../bot/components/solenoid.tsx | 7 +- .../three_d_garden/bot/components/tools.tsx | 26 +- frontend/three_d_garden/constants.ts | 8 +- ...rizontal.glb => cc_support_horizontal.glb} | Bin ...c_vertical.glb => cc_support_vertical.glb} | Bin 26 files changed, 666 insertions(+), 291 deletions(-) create mode 100644 frontend/demo/lua_runner/__tests__/subs_test.ts create mode 100644 frontend/demo/lua_runner/stubs.ts rename public/3D/models/{cc_horizontal.glb => cc_support_horizontal.glb} (100%) rename public/3D/models/{cc_vertical.glb => cc_support_vertical.glb} (100%) diff --git a/app/mutations/devices/seeders/constants.rb b/app/mutations/devices/seeders/constants.rb index f70e39aa88..ca3ce2bb3b 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 = -350 TROUGH_SPACING = 25 module Names diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 908a09b963..6274309622 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -140,6 +140,11 @@ def before_product_line_seeder three_d_garden: true, show_points: false ) + device + .fbos_config + .update!( + safe_height: -200, + ) end def after_product_line_seeder(product_line) 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/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 732080bc9c..30ee86c3c8 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -4,6 +4,8 @@ import { } from "../../../__test_support__/resource_index_builder"; import { fakeFirmwareConfig, + fakeWebAppConfig, + fakeFbosConfig, fakePoint, fakeSequence, fakeTool, fakeToolSlot, @@ -325,6 +327,13 @@ describe("runDemoLuaCode()", () => { console.error = jest.fn(); jest.useFakeTimers(); mockLocked = false; + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([ + fakeFbosConfig(), + firmwareConfig, + fakeWebAppConfig(), + ]); }); it("runs print", () => { @@ -352,7 +361,7 @@ describe("runDemoLuaCode()", () => { 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]); + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); runDemoLuaCode( "print(garden_size().x)\n" + "print(garden_size().y)\n" + @@ -582,6 +591,63 @@ describe("runDemoLuaCode()", () => { }); }); + it("runs find_axis_length: x", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_x = 500; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("find_axis_length(\"x\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 2, z: 3 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 100, y: 2, z: 3 }, + }); + }); + + it("runs find_axis_length: y", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_y = 500; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("find_axis_length(\"y\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 0, z: 3 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 100, z: 3 }, + }); + }); + + it("runs find_axis_length: z", () => { + const firmwareConfig = fakeFirmwareConfig(); + firmwareConfig.body.movement_axis_nr_steps_z = 2500; + firmwareConfig.body.movement_home_up_z = 0; + mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("find_axis_length(\"z\")"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 2, z: 0 }, + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 1, y: 2, z: 100 }, + }); + }); + it("runs toggle_pin", () => { runDemoLuaCode("toggle_pin(5)"); jest.runAllTimers(); @@ -638,6 +704,22 @@ describe("runDemoLuaCode()", () => { expect(info).not.toHaveBeenCalled(); }); + it("runs env", () => { + runDemoLuaCode("print(env(\"foo\"))"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(""); + expect(info).not.toHaveBeenCalled(); + }); + + it("runs soil_height", () => { + runDemoLuaCode("print(soil_height(0, 0))"); + jest.runAllTimers(); + expect(console.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; @@ -748,6 +830,36 @@ describe("runDemoLuaCode()", () => { }); }); + 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()]); + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_absolute(0, 0, 1000)"); + jest.runAllTimers(); + expect(console.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()]); + mockPosition = { x: 1, y: 2, z: 3 }; + runDemoLuaCode("move_absolute(0, 0, -1000)"); + jest.runAllTimers(); + expect(console.error).not.toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith({ + type: Actions.DEMO_SET_POSITION, + payload: { x: 0, y: 0, z: -100 }, + }); + }); + it("runs move: y", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move{ y = 1 }"); @@ -865,9 +977,9 @@ describe("csToLua()", () => { * [ y ] dispense * [ y ] emergency_lock * [ y ] emergency_unlock - * [ ] env + * [ y ] env * [ ] fbos_version - * [ ] find_axis_length + * [ y ] find_axis_length * [ y ] find_home * [ ] firmware_version * [ y ] garden_size @@ -908,7 +1020,7 @@ describe("csToLua()", () => { * [ y ] set_job_progress * [ ] set_pin_io_mode * [ ] soft_stop - * [ ] soil_height + * [ y ] soil_height * [ ] sort * [ ] take_photo_raw * [ ] take_photo diff --git a/frontend/demo/lua_runner/__tests__/subs_test.ts b/frontend/demo/lua_runner/__tests__/subs_test.ts new file mode 100644 index 0000000000..1d884f2c6e --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/subs_test.ts @@ -0,0 +1,54 @@ +import { + fakeFbosConfig, + fakeFirmwareConfig, + fakeWebAppConfig, +} from "../../../__test_support__/fake_state/resources"; + +let mockFirmwareConfig = fakeFirmwareConfig(); +let mockWebAppConfig = fakeWebAppConfig(); +let mockFbosConfig = fakeFbosConfig(); +jest.mock("../../../resources/getters", () => ({ + getFirmwareConfig: () => mockFirmwareConfig, + getWebAppConfig: () => mockWebAppConfig, + getFbosConfig: () => mockFbosConfig, +})); + +import { 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); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/util_test.ts b/frontend/demo/lua_runner/__tests__/util_test.ts index fa45234ffb..622842f7a7 100644 --- a/frontend/demo/lua_runner/__tests__/util_test.ts +++ b/frontend/demo/lua_runner/__tests__/util_test.ts @@ -91,6 +91,57 @@ describe("csToLua()", () => { expect(csToLua(command)).toEqual("move{y=1}"); }); + it("converts celery script to lua: move all axes to coordinate", () => { + 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(csToLua(command)).toEqual("move{x=1, y=2, z=3}"); + }); + + it("converts celery script to lua: move axis to coordinate", () => { + 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(csToLua(command)).toEqual("move{x=1}"); + }); + + it("converts celery script to lua: move no coordinate", () => { + const command: Move = { + kind: "move", + args: {}, + body: [ + { + kind: "axis_overwrite", + args: { + axis: "all", + axis_operand: { kind: "numeric", args: { number: 1 } }, + }, + }, + ], + }; + expect(csToLua(command)).toEqual("move{}"); + }); + it("converts celery script to lua: move no body", () => { const command: Move = { kind: "move", args: {} }; expect(csToLua(command)).toEqual("move{}"); diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 1b6a844898..ed254bd846 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,15 +1,17 @@ -import { PercentageProgress, Xyz } from "farmbot"; +import { PercentageProgress } from "farmbot"; import { info } from "../../toast/toast"; import { store } from "../../redux/store"; import { Actions } from "../../constants"; import { validBotLocationData } from "../../util/location"; import { TOAST_OPTIONS } from "../../toast/constants"; -import { Action } from "./interfaces"; +import { Action, XyzNumber } from "./interfaces"; import { edit, save } from "../../api/crud"; import { getDeviceAccountSettings } from "../../resources/selectors"; import { UnknownAction } from "redux"; +import { getFirmwareSettings, getGardenSize } from "./stubs"; +import { clamp } from "lodash"; -const almostEqual = (a: Record, b: Record) => { +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 && @@ -17,9 +19,9 @@ const almostEqual = (a: Record, b: Record) => { }; const movementChunks = ( - current: Record, - target: Record, -): Record[] => { + current: XyzNumber, + target: XyzNumber, +): XyzNumber[] => { const dx = target.x - current.x; const dy = target.y - current.y; const dz = target.z - current.z; @@ -32,7 +34,7 @@ const movementChunks = ( z: dz / length, }; const steps = Math.floor(length / 100); - const chunks: Record[] = []; + const chunks: XyzNumber[] = []; for (let i = 0; i <= steps; i++) { const step = { x: current.x + direction.x * 100 * i, @@ -47,6 +49,19 @@ const movementChunks = ( 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 expandActions = (actions: Action[]): Action[] => { const expanded: Action[] = []; const { position } = validBotLocationData( @@ -56,7 +71,7 @@ const expandActions = (actions: Action[]): Action[] => { y: position.y as number, z: position.z as number, }; - const addPosition = (position: Record) => { + const addPosition = (position: XyzNumber) => { expanded.push({ type: "wait_ms", args: [500], @@ -66,7 +81,7 @@ const expandActions = (actions: Action[]): Action[] => { args: [position.x, position.y, position.z], }); }; - const setCurrent = (position: Record) => { + const setCurrent = (position: XyzNumber) => { current.x = position.x; current.y = position.y; current.z = position.z; @@ -74,29 +89,29 @@ const expandActions = (actions: Action[]): Action[] => { actions.map(action => { switch (action.type) { case "move_absolute": - const target = { + const moveAbsoluteTarget = clampTarget({ x: action.args[0] as number, y: action.args[1] as number, z: action.args[2] as number, - }; - movementChunks(current, target).map(addPosition); - setCurrent(target); + }); + movementChunks(current, moveAbsoluteTarget).map(addPosition); + setCurrent(moveAbsoluteTarget); break; case "move_relative": - const moveRelativeTarget = { + 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).map(addPosition); setCurrent(moveRelativeTarget); break; case "move": - const moveTarget = { + const moveTarget = clampTarget({ x: (action.args[0] as number | undefined) ?? current.x, y: (action.args[1] as number | undefined) ?? current.y, z: (action.args[2] as number | undefined) ?? current.z, - }; + }); movementChunks(current, moveTarget).map(addPosition); setCurrent(moveTarget); break; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index f722801276..669e860d37 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -1,3 +1,5 @@ +import { Xyz } from "farmbot"; + export interface Action { type: | "move_absolute" @@ -16,3 +18,5 @@ export interface Action { | "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 index 937c217b05..c5f33f1045 100644 --- a/frontend/demo/lua_runner/lua.ts +++ b/frontend/demo/lua_runner/lua.ts @@ -275,6 +275,7 @@ function dismount_tool() -- Put the tool in the slot job(80, "Putting tool in slot") move_absolute(slot.x, slot.y, slot.z, 50) + update_device({mounted_tool_id = 0}) -- Dismount tool job(90, "Dismounting tool") @@ -287,7 +288,6 @@ function dismount_tool() return else job(100, "Complete") - update_device({mounted_tool_id = 0}) toast(tool_name .. " dismounted", "success") end end @@ -554,6 +554,7 @@ function mount_tool(input) -- Mount the tool job(60, "Mounting tool") move{z=slot.z} + update_device({mounted_tool_id = slot.tool_id}) -- Pull the tool out of the slot at 50% speed job(80, "Pulling tool out") @@ -574,7 +575,6 @@ function mount_tool(input) return else job(100, "Complete") - update_device({mounted_tool_id = slot.tool_id}) toast(tool.name .. " mounted", "success") end end diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index 3cf0789c43..778d29ec97 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -4,17 +4,15 @@ import { selectAllPoints, selectAllTools, selectAllToolSlotPointers, } from "../../resources/selectors"; import { - ParameterApplication, RpcRequest, TaggedFirmwareConfig, - Xyz, + ParameterApplication, RpcRequest, Xyz, } from "farmbot"; import { store } from "../../redux/store"; import { sortGroupBy } from "../../point_groups/point_group_sort"; -import { calculateAxialLengths } from "../../controls/move/direction_axes_props"; -import { getFirmwareConfig } from "../../resources/getters"; import { LUA_HELPERS } from "./lua"; import { createRecursiveNotImplemented, csToLua, jsToLua, luaToJs } from "./util"; -import { Action } from "./interfaces"; +import { Action, XyzNumber } from "./interfaces"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; +import { getGardenSize, getSafeZ } from "./stubs"; export const runLua = (luaCode: string, variables: ParameterApplication[]): Action[] => { @@ -207,7 +205,7 @@ export const runLua = const args = []; const n = lua.lua_gettop(L); if (n == 1) { - const params = luaToJs(L, 1) as Record; + 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++) { @@ -244,6 +242,36 @@ export const runLua = }); lua.lua_setfield(L, envIndex, to_luastring("go_to_home")); + lua.lua_pushjsfunction(L, () => { + const axis = luaToJs(L, -1) as string; + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? -9999 : 0, + axis == "y" ? -9999 : 0, + axis == "z" ? -9999 : 0, + ], + }); + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? 9999 : 0, + axis == "y" ? 9999 : 0, + axis == "z" ? 9999 : 0, + ], + }); + actions.push({ + type: "move_relative", + args: [ + axis == "x" ? -9999 : 0, + axis == "y" ? -9999 : 0, + axis == "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] }); @@ -269,13 +297,25 @@ export const runLua = lua.lua_setfield(L, envIndex, to_luastring("update_device")); lua.lua_pushjsfunction(L, () => { - jsToLua(L, 0); + jsToLua(L, getSafeZ()); return 1; }); lua.lua_setfield(L, envIndex, to_luastring("safe_z")); lua.lua_pushjsfunction(L, () => { - const args = luaToJs(L, 1) as Partial>; + jsToLua(L, ""); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("env")); + + lua.lua_pushjsfunction(L, () => { + jsToLua(L, -500); + return 1; + }); + lua.lua_setfield(L, envIndex, to_luastring("soil_height")); + + lua.lua_pushjsfunction(L, () => { + const args = luaToJs(L, 1) as Partial; actions.push({ type: "move", args: [args.x, args.y, args.z] }); return 0; }); @@ -312,9 +352,7 @@ export const runLua = lua.lua_setfield(L, envIndex, to_luastring("toggle_pin")); lua.lua_pushjsfunction(L, () => { - const fwConfig = getFirmwareConfig(store.getState().resources.index); - const firmwareSettings = (fwConfig as TaggedFirmwareConfig).body; - const lengths = calculateAxialLengths({ firmwareSettings }); + const lengths = getGardenSize(); jsToLua(L, lengths); return 1; }); diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts new file mode 100644 index 0000000000..55cb95cd1c --- /dev/null +++ b/frontend/demo/lua_runner/stubs.ts @@ -0,0 +1,46 @@ +import { store } from "../../redux/store"; +import { + 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"; + +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; +}; diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts index 1cd97e585f..ff183c4b37 100644 --- a/frontend/demo/lua_runner/util.ts +++ b/frontend/demo/lua_runner/util.ts @@ -132,8 +132,18 @@ export const csToLua = (command: RpcRequestBodyItem): string => { .filter(part => part.kind == "axis_overwrite") .map(axisOverwrite => { const { axis, axis_operand } = axisOverwrite.args; - if (axis_operand.kind == "numeric") { - return `${axis}=${axis_operand.args.number}`; + if (axis == "all") { + if (axis_operand.kind == "coordinate") { + const { args } = axis_operand; + return `x=${args.x}, y=${args.y}, z=${args.z}`; + } + } else { + if (axis_operand.kind == "numeric") { + return `${axis}=${axis_operand.args.number}`; + } + if (axis_operand.kind == "coordinate") { + return `${axis}=${axis_operand.args[axis]}`; + } } }) .join(", "); diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index cb534bc76b..e59718249b 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()), @@ -439,6 +440,32 @@ describe("moveAbsolute()", () => { }); 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) @@ -448,27 +475,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(); @@ -483,48 +490,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 } }, + } + }, ], }], }); @@ -540,32 +528,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()", () => { @@ -647,6 +628,26 @@ describe("findHome()", () => { }); }); +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()", () => { it("knows if it is a log or not", () => { expect(actions.isLog({})).toBe(false); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 404ebeb008..748231243c 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"; @@ -374,7 +375,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 => ({ @@ -408,8 +408,13 @@ 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)); } @@ -480,8 +485,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/reducer.ts b/frontend/devices/reducer.ts index 67f8b934f9..b22f1fac81 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -154,6 +154,7 @@ export const botReducer = generateReducer(initialState()) }) .add(Actions.DEMO_SET_ESTOP, (s, { payload }) => { s.hardware.informational_settings.locked = payload; + s.hardware.pins = {}; return s; }) .add(Actions.PING_OK, (s) => { 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/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 7e61e9af7f..66a0f9e6d5 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -28,7 +28,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 +39,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", () => { diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 6c921eddcc..e14a9c87ec 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -25,10 +25,10 @@ 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"; @@ -488,7 +488,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 +604,7 @@ export const Bot = (props: FarmbotModelProps) => { config={config} aluminumTexture={aluminumTexture} beamShape={beamShape} /> - + ", () => { - 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/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..52d41c2b7b 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -169,9 +169,9 @@ export const Tools = (props: ToolsProps) => { const navigate = useNavigate(); return { if (slotProps.id && !isUndefined(props.dispatch) && @@ -227,13 +227,13 @@ export const Tools = (props: ToolsProps) => { rotaryToolImplementRef.current.rotation.z = time * speed; } }); - + const X = 5.5; switch (toolProps.toolName) { case ToolName.rotaryTool: return { return { return { return { return { return { return { return @@ -391,7 +391,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/constants.ts b/frontend/three_d_garden/constants.ts index a873749c45..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", diff --git a/public/3D/models/cc_horizontal.glb b/public/3D/models/cc_support_horizontal.glb similarity index 100% rename from public/3D/models/cc_horizontal.glb rename to public/3D/models/cc_support_horizontal.glb diff --git a/public/3D/models/cc_vertical.glb b/public/3D/models/cc_support_vertical.glb similarity index 100% rename from public/3D/models/cc_vertical.glb rename to public/3D/models/cc_support_vertical.glb From e9ab867ceefc86c51ed9754e515ca161c1a0c51b Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 15 Jul 2025 17:00:27 -0700 Subject: [PATCH 13/54] add move and execute to lua runner --- app/views/dashboard/demo.html.erb | 4 +- .../demo/lua_runner/__tests__/actions_test.ts | 434 +++++++++++++++++- .../demo/lua_runner/__tests__/index_test.ts | 230 +++++++--- .../demo/lua_runner/__tests__/util_test.ts | 103 +++-- frontend/demo/lua_runner/actions.ts | 135 +++++- frontend/demo/lua_runner/index.ts | 29 +- frontend/demo/lua_runner/interfaces.ts | 1 + frontend/demo/lua_runner/run.ts | 29 +- frontend/demo/lua_runner/util.ts | 36 +- frontend/devices/__tests__/actions_test.ts | 16 +- frontend/devices/actions.ts | 9 +- .../__tests__/selectors_by_id_test.ts | 8 + .../resources/__tests__/selectors_test.ts | 27 ++ frontend/resources/selectors_by_id.ts | 26 ++ .../__tests__/triangle_functions_test.ts | 51 ++ .../__tests__/triangles_test.ts | 52 +-- frontend/three_d_garden/garden_model.tsx | 6 +- frontend/three_d_garden/triangle_functions.ts | 53 +++ frontend/three_d_garden/triangles.ts | 54 --- 19 files changed, 1029 insertions(+), 274 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/triangle_functions_test.ts create mode 100644 frontend/three_d_garden/triangle_functions.ts 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/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 47eb296fb5..1dddc05e89 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -1,14 +1,440 @@ +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { + fakePlant, + fakeTool, + fakeToolSlot, +} 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 { Move, ParameterApplication } from "farmbot"; import { TOAST_OPTIONS } from "../../../toast/constants"; import { info } from "../../../toast/toast"; -import { runActions } from "../actions"; +import { calculateMove, runActions } from "../actions"; describe("runActions()", () => { it("runs actions", () => { jest.useFakeTimers(); - runActions([ - { type: "send_message", args: ["info", "Hello, world!", "toast"] }, - ]); + runActions( + [ + { type: "send_message", args: ["info", "Hello, world!", "toast"] }, + ], + []); jest.runAllTimers(); expect(info).toHaveBeenCalledWith("Hello, world!", TOAST_OPTIONS().info); }); }); + +describe("calculateMove()", () => { + 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({ x: 2, y: 2, z: 3 }); + }); + + 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({ x: 2, y: 3, z: 4 }); + }); + + 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({ x: 2, y: 2, z: 3 }); + }); + + 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({ x: 2, y: 4, z: 6 }); + }); + + 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({ x: 3, y: 2, z: 3 }); + }); + + 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({ x: 1, y: 1, z: 1 }); + }); + + 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({ x: 1, y: 0, z: 0 }); + }); + + 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({ x: 1, y: 2, z: 3 }); + }); + + 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({ x: 1, y: 0, z: 0 }); + }); + + 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; + 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({ x: 1, y: 2, z: 3 }); + }); + + 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({ x: 0, y: 0, z: 0 }); + }); + + 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({ x: 1, y: 2, z: 3 }); + }); + + 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({ x: 1, y: 2, z: 3 }); + }); + + 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({ x: 0, y: 0, z: 0 }); + }); + + 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({ x: 0, y: 0, z: 0 }); + }); + + 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({ x: 0, y: 0, z: 3 }); + }); + + 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({ x: 0, y: 0, z: 3 }); + }); + + 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({ x: 0, y: 0, z: 0 }); + }); + + it("handles missing body", () => { + const command: Move = { kind: "move", args: {} }; + expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) + .toEqual({ x: 0, y: 0, z: 0 }); + }); +}); diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 30ee86c3c8..4937ba863a 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -33,11 +33,18 @@ jest.mock("../../../api/crud", () => ({ save: jest.fn(), })); -import { ParameterApplication, TaggedSequence } from "farmbot"; +import { + Execute, FindHome, Move, ParameterApplication, TaggedSequence, +} from "farmbot"; import { Actions } from "../../../constants"; import { store } from "../../../redux/store"; -import { info } from "../../../toast/toast"; -import { csToLua, runDemoLuaCode, runDemoSequence } from ".."; +import { error, info } from "../../../toast/toast"; +import { + collectDemoSequenceActions, + csToLua, + runDemoLuaCode, + runDemoSequence, +} from ".."; import { TOAST_OPTIONS } from "../../../toast/constants"; import { edit, save } from "../../../api/crud"; @@ -45,7 +52,6 @@ describe("runDemoSequence()", () => { beforeEach(() => { localStorage.setItem("myBotIs", "online"); console.log = jest.fn(); - console.error = jest.fn(); jest.useFakeTimers(); }); @@ -66,7 +72,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("1"); }); @@ -88,7 +94,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("text"); }); @@ -110,7 +116,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); }); @@ -139,7 +145,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); }); @@ -165,7 +171,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("undefined"); }); @@ -190,7 +196,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("1"); }); @@ -213,7 +219,7 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("undefined"); }); @@ -239,7 +245,7 @@ describe("runDemoSequence()", () => { "Variable \"Other\" of type identifier not implemented.", TOAST_OPTIONS().error); expect(console.log).toHaveBeenCalledWith("undefined"); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); }); it("runs non-lua sequence step", () => { @@ -252,11 +258,43 @@ describe("runDemoSequence()", () => { const ri = buildResourceIndex([sequence]).index; runDemoSequence(ri, sequence.body.id, []); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); expect(console.log).not.toHaveBeenCalled(); }); + it("runs move sequence step", () => { + mockPosition = { 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).not.toHaveBeenCalled(); + }); + it("handles missing variables", () => { const sequence = fakeSequence(); sequence.body.body = [{ @@ -271,7 +309,7 @@ describe("runDemoSequence()", () => { "Variable \"Number\" of type undefined not implemented.", TOAST_OPTIONS().error); expect(console.log).toHaveBeenCalledWith("undefined"); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); }); it("handles missing sequence body", () => { @@ -286,7 +324,7 @@ describe("runDemoSequence()", () => { jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); }); it("handles load error", () => { @@ -298,9 +336,8 @@ describe("runDemoSequence()", () => { jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - "Lua load error:", - "[string \"!\"]:1: unexpected symbol near '!'", + expect(error).toHaveBeenCalledWith( + "Lua load error: [string \"!\"]:1: unexpected symbol near '!'", ); }); @@ -313,10 +350,41 @@ describe("runDemoSequence()", () => { jest.runAllTimers(); expect(console.log).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - "Lua call error:", - expect.stringContaining("attempt to perform arithmetic"), - ); + 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(ri, 1, []); + expect(actions).toEqual([ + { type: "find_home", args: ["x"] }, + { type: "find_home", args: ["y"] }, + ]); }); }); @@ -324,7 +392,6 @@ describe("runDemoLuaCode()", () => { beforeEach(() => { localStorage.setItem("myBotIs", "online"); console.log = jest.fn(); - console.error = jest.fn(); jest.useFakeTimers(); mockLocked = false; const firmwareConfig = fakeFirmwareConfig(); @@ -339,7 +406,7 @@ describe("runDemoLuaCode()", () => { it("runs print", () => { runDemoLuaCode("print(\"Hello, world!\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("Hello, world!"); }); @@ -351,7 +418,7 @@ describe("runDemoLuaCode()", () => { print(a, false, true, nil, {1}, {a = {b = 1}}, f) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith( "4 false true undefined [1] {\"a\":{\"b\":1}} \"\""); }); @@ -367,7 +434,7 @@ describe("runDemoLuaCode()", () => { "print(garden_size().y)\n" + "print(garden_size().z)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("1000"); expect(console.log).toHaveBeenCalledWith("2000"); expect(console.log).toHaveBeenCalledWith("500"); @@ -385,7 +452,7 @@ describe("runDemoLuaCode()", () => { print(type(data), #data) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("table 1"); expect(info).not.toHaveBeenCalled(); }); @@ -404,7 +471,7 @@ describe("runDemoLuaCode()", () => { print(type(data), #data) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("table 1"); expect(info).not.toHaveBeenCalled(); }); @@ -421,7 +488,7 @@ describe("runDemoLuaCode()", () => { print(type(data), #data) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("table 1"); expect(info).not.toHaveBeenCalled(); }); @@ -436,7 +503,7 @@ describe("runDemoLuaCode()", () => { print(data) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("false"); expect(info).toHaveBeenCalledWith( "API call GET /api/other not implemented.", @@ -456,46 +523,69 @@ describe("runDemoLuaCode()", () => { } `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + 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]); + mockPosition = { 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).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + }); + it("runs cs_eval: no body", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("cs_eval{}"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalled(); }); it("runs toast", () => { runDemoLuaCode("toast(\"test\", \"info\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); }); it("runs toast: default", () => { runDemoLuaCode("toast(\"test\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); }); it("runs debug", () => { runDemoLuaCode("debug(\"test\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().debug); }); it("runs send_message", () => { runDemoLuaCode("send_message(\"info\", \"test\", \"toast\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); }); @@ -508,7 +598,7 @@ describe("runDemoLuaCode()", () => { }) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_JOB_PROGRESS, payload: ["job", { @@ -532,7 +622,7 @@ describe("runDemoLuaCode()", () => { }) `); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_JOB_PROGRESS, payload: ["job", { @@ -551,7 +641,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("find_home(\"all\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 0, z: 0 }, @@ -562,7 +652,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("go_to_home(\"all\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 0, z: 0 }, @@ -573,7 +663,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("go_to_home(\"x\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 2, z: 3 }, @@ -584,7 +674,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("go_to_home(\"y\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 0, z: 3 }, @@ -599,7 +689,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("find_axis_length(\"x\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 2, z: 3 }, @@ -618,7 +708,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("find_axis_length(\"y\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 0, z: 3 }, @@ -637,7 +727,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("find_axis_length(\"z\")"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 2, z: 0 }, @@ -651,7 +741,7 @@ describe("runDemoLuaCode()", () => { it("runs toggle_pin", () => { runDemoLuaCode("toggle_pin(5)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_TOGGLE_PIN, payload: 5, @@ -661,7 +751,7 @@ describe("runDemoLuaCode()", () => { it("runs write_pin", () => { runDemoLuaCode("write_pin(5, \"digital\", 1)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_WRITE_PIN, payload: { pin: 5, mode: "digital", value: 1 }, @@ -671,7 +761,7 @@ describe("runDemoLuaCode()", () => { it("runs on", () => { runDemoLuaCode("on(5)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_WRITE_PIN, payload: { pin: 5, mode: "digital", value: 1 }, @@ -682,14 +772,14 @@ describe("runDemoLuaCode()", () => { mockLocked = true; runDemoLuaCode("on(5)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).not.toHaveBeenCalled(); }); it("runs off", () => { runDemoLuaCode("off(5)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_WRITE_PIN, payload: { pin: 5, mode: "digital", value: 0 }, @@ -699,7 +789,7 @@ describe("runDemoLuaCode()", () => { it("runs safe_z", () => { runDemoLuaCode("print(safe_z())"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); expect(info).not.toHaveBeenCalled(); }); @@ -707,7 +797,7 @@ describe("runDemoLuaCode()", () => { it("runs env", () => { runDemoLuaCode("print(env(\"foo\"))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith(""); expect(info).not.toHaveBeenCalled(); }); @@ -715,7 +805,7 @@ describe("runDemoLuaCode()", () => { it("runs soil_height", () => { runDemoLuaCode("print(soil_height(0, 0))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("-500"); expect(info).not.toHaveBeenCalled(); }); @@ -726,7 +816,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([device]); runDemoLuaCode("print(get_device(\"mounted_tool_id\"))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("1"); expect(info).not.toHaveBeenCalled(); }); @@ -737,7 +827,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([device]); runDemoLuaCode("print(get_device(\"mounted_tool_id\"))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("false"); expect(info).not.toHaveBeenCalled(); }); @@ -748,7 +838,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([device]); runDemoLuaCode("update_device{ mounted_tool_id = 1 }"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); expect(edit).toHaveBeenCalledWith(device, { mounted_tool_id: 1 }); @@ -761,7 +851,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([device]); runDemoLuaCode("print(read_pin(63))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); expect(info).not.toHaveBeenCalled(); }); @@ -772,7 +862,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([device]); runDemoLuaCode("print(read_pin(63))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("1"); expect(info).not.toHaveBeenCalled(); }); @@ -781,7 +871,7 @@ describe("runDemoLuaCode()", () => { mockResources = buildResourceIndex([]); runDemoLuaCode("print(read_pin(5))"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); expect(info).not.toHaveBeenCalled(); }); @@ -790,7 +880,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move_relative(1, 0, 0)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 2, z: 3 }, @@ -801,7 +891,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 0, y: 0, z: 0 }; runDemoLuaCode("move_relative(0, 0, 0)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 0, z: 0 }, @@ -812,7 +902,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move_absolute(1, 0, 0)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 0, z: 0 }, @@ -823,7 +913,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move_absolute{ x = 1, y = 0, z = 0 }"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 0, z: 0 }, @@ -838,7 +928,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move_absolute(0, 0, 1000)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 0, z: 100 }, @@ -853,7 +943,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move_absolute(0, 0, -1000)"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 0, z: -100 }, @@ -864,7 +954,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move{ y = 1 }"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 1, y: 1, z: 3 }, @@ -875,7 +965,7 @@ describe("runDemoLuaCode()", () => { mockPosition = { x: 1, y: 2, z: 3 }; runDemoLuaCode("move{ x = 0, z = 0 }"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, payload: { x: 0, y: 2, z: 0 }, @@ -885,7 +975,7 @@ describe("runDemoLuaCode()", () => { it("runs emergency_lock", () => { runDemoLuaCode("emergency_lock()"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_ESTOP, payload: true, @@ -895,7 +985,7 @@ describe("runDemoLuaCode()", () => { it("runs emergency_unlock", () => { runDemoLuaCode("emergency_unlock()"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_ESTOP, payload: false, @@ -906,7 +996,7 @@ describe("runDemoLuaCode()", () => { mockLocked = true; runDemoLuaCode("emergency_unlock()"); jest.runAllTimers(); - expect(console.error).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_ESTOP, payload: false, diff --git a/frontend/demo/lua_runner/__tests__/util_test.ts b/frontend/demo/lua_runner/__tests__/util_test.ts index 622842f7a7..09d35e0ea2 100644 --- a/frontend/demo/lua_runner/__tests__/util_test.ts +++ b/frontend/demo/lua_runner/__tests__/util_test.ts @@ -1,3 +1,17 @@ +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { + fakePeripheral, +} from "../../../__test_support__/fake_state/resources"; +let mockResources = buildResourceIndex([]); +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => ({ resources: mockResources }), + }, +})); + import { csToLua } from "../util"; import { EmergencyLock, EmergencyUnlock, FindHome, Home, Lua, Move, MoveAbsolute, @@ -88,71 +102,58 @@ describe("csToLua()", () => { }, ], }; - expect(csToLua(command)).toEqual("move{y=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 all axes to coordinate", () => { - 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(csToLua(command)).toEqual("move{x=1, y=2, z=3}"); + 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: move axis to coordinate", () => { - const command: Move = { - kind: "move", - args: {}, - body: [ - { - kind: "axis_overwrite", - args: { - axis: "x", - axis_operand: { kind: "coordinate", args: { x: 1, y: 2, z: 3 } }, - }, - }, - ], + 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("move{x=1}"); + expect(csToLua(command)).toEqual("write_pin(1, \"digital\", 1)"); }); - it("converts celery script to lua: move no coordinate", () => { - const command: Move = { - kind: "move", - args: {}, - body: [ - { - kind: "axis_overwrite", - args: { - axis: "all", - axis_operand: { kind: "numeric", args: { number: 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("move{}"); - }); - - it("converts celery script to lua: move no body", () => { - const command: Move = { kind: "move", args: {} }; - expect(csToLua(command)).toEqual("move{}"); + expect(csToLua(command)).toEqual("write_pin(2, \"digital\", 1)"); }); - it("converts celery script to lua: write_pin", () => { + it("converts celery script to lua: missing peripheral", () => { + mockResources = buildResourceIndex([]); const command: WritePin = { kind: "write_pin", - args: { pin_number: 1, pin_mode: 0, pin_value: 1 }, + 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(1, \"digital\", 1)"); + expect(csToLua(command)).toEqual(""); }); it("converts celery script to lua: write_pin analog", () => { diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index ed254bd846..79aa41e31b 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,4 +1,6 @@ -import { PercentageProgress } from "farmbot"; +import { + Identifier, MoveBodyItem, ParameterApplication, PercentageProgress, +} from "farmbot"; import { info } from "../../toast/toast"; import { store } from "../../redux/store"; import { Actions } from "../../constants"; @@ -6,10 +8,15 @@ import { validBotLocationData } from "../../util/location"; import { TOAST_OPTIONS } from "../../toast/constants"; import { Action, XyzNumber } from "./interfaces"; import { edit, save } from "../../api/crud"; -import { getDeviceAccountSettings } from "../../resources/selectors"; +import { + getDeviceAccountSettings, + maybeFindPointById, + maybeFindSlotByToolId, +} from "../../resources/selectors"; import { UnknownAction } from "redux"; import { getFirmwareSettings, getGardenSize } from "./stubs"; -import { clamp } from "lodash"; +import { clamp, clone } from "lodash"; +import { getZFunc, TriangleData } from "../../three_d_garden/triangle_functions"; const almostEqual = (a: XyzNumber, b: XyzNumber) => { const epsilon = 0.01; @@ -62,7 +69,10 @@ const clampTarget = (target: XyzNumber): XyzNumber => { return clamped; }; -const expandActions = (actions: Action[]): Action[] => { +const expandActions = ( + actions: Action[], + variables: ParameterApplication[] | undefined, +): Action[] => { const expanded: Action[] = []; const { position } = validBotLocationData( store.getState().bot.hardware.location_data); @@ -106,6 +116,13 @@ const expandActions = (actions: Action[]): Action[] => { movementChunks(current, moveRelativeTarget).map(addPosition); setCurrent(moveRelativeTarget); break; + case "_move": + const moveItems = JSON.parse("" + action.args[0]) as MoveBodyItem[]; + const actualMoveTarget = clampTarget( + calculateMove(moveItems, current, variables)); + movementChunks(current, actualMoveTarget).map(addPosition); + setCurrent(actualMoveTarget); + break; case "move": const moveTarget = clampTarget({ x: (action.args[0] as number | undefined) ?? current.x, @@ -139,9 +156,12 @@ const expandActions = (actions: Action[]): Action[] => { const pending = new Set>(); -export const runActions = (actions: Action[]) => { +export const runActions = ( + actions: Action[], + variables: ParameterApplication[] | undefined, +) => { let delay = 0; - expandActions(actions).map(action => { + expandActions(actions, variables).map(action => { // eslint-disable-next-line complexity const getFunc = () => { const estopped = store.getState().bot.hardware.informational_settings.locked; @@ -242,3 +262,106 @@ export const runActions = (actions: Action[]) => { func && pending.add(setTimeout(func, delay)); }); }; + +export const calculateMove = ( + body: MoveBodyItem[] | undefined, + current: XyzNumber, + variables: ParameterApplication[] | undefined, +) => { + const pos = clone(current); + // eslint-disable-next-line complexity + (body || []).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; + } + 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; + } + if (item.args.axis == "all") { + pos.x = toolSlot.body.x; + pos.y = toolSlot.body.y; + pos.z = toolSlot.body.z; + } else { + pos[item.args.axis] = toolSlot.body[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; + } + 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; + } + break; + case "special_value": + if (item.args.axis_operand.args.label == "soil_height" + && item.args.axis == "z") { + const triangles = JSON.parse( + sessionStorage.getItem("triangles") || "[]") as TriangleData[]; + const getZ = getZFunc(triangles, -500); + pos.z = getZ(pos.x, pos.y); + } + break; + } + return; + } + }); + return pos; +}; diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts index 7cd4b8e2b3..58e8e69f55 100644 --- a/frontend/demo/lua_runner/index.ts +++ b/frontend/demo/lua_runner/index.ts @@ -8,10 +8,10 @@ import { csToLua } from "./util"; export const runDemoLuaCode = (luaCode: string) => { const actions = runLua(luaCode, []); - runActions(actions); + runActions(actions, []); }; -export const runDemoSequence = ( +export const collectDemoSequenceActions = ( resources: ResourceIndex, sequenceId: number, variables: ParameterApplication[] | undefined, @@ -19,11 +19,28 @@ export const runDemoSequence = ( const sequence = findSequenceById(resources, sequenceId); const actions: Action[] = []; (sequence.body.body as SequenceBodyItem[]).map(step => { - const lua = step.kind === "lua" ? step.args.lua : csToLua(step); - const stepActions = runLua(lua, variables || []); - actions.push(...stepActions); + if (step.kind == "execute") { + const seqActions = collectDemoSequenceActions( + resources, + step.args.sequence_id, + step.body); + actions.push(...seqActions); + } else { + const lua = step.kind === "lua" ? step.args.lua : csToLua(step); + const stepActions = runLua(lua, variables || []); + actions.push(...stepActions); + } }); - runActions(actions); + return actions; +}; + +export const runDemoSequence = ( + resources: ResourceIndex, + sequenceId: number, + variables: ParameterApplication[] | undefined, +) => { + const actions = collectDemoSequenceActions(resources, sequenceId, variables); + runActions(actions, variables); }; export { csToLua }; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index 669e860d37..460181d904 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -5,6 +5,7 @@ export interface Action { | "move_absolute" | "move_relative" | "move" + | "_move" | "toggle_pin" | "emergency_lock" | "emergency_unlock" diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index 778d29ec97..a3cf705bed 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -13,6 +13,8 @@ import { createRecursiveNotImplemented, csToLua, jsToLua, luaToJs } from "./util import { Action, XyzNumber } from "./interfaces"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; import { getGardenSize, getSafeZ } from "./stubs"; +import { error } from "../../toast/toast"; +import { collectDemoSequenceActions } from "./index"; export const runLua = (luaCode: string, variables: ParameterApplication[]): Action[] => { @@ -161,8 +163,16 @@ export const runLua = lua.lua_pushjsfunction(L, () => { const cmd = (luaToJs(L, 1) as RpcRequest).body?.[0]; if (!cmd) { return 0; } - const luaActions = runLua(csToLua(cmd), variables); - actions.push(...luaActions); + if (cmd.kind == "execute") { + const ri = store.getState().resources.index; + const sequenceId = cmd.args.sequence_id; + const seqVariables = cmd.body; + const seqActions = collectDemoSequenceActions(ri, sequenceId, seqVariables); + actions.push(...seqActions); + } else { + const luaActions = runLua(csToLua(cmd), variables); + actions.push(...luaActions); + } return 0; }); lua.lua_setfield(L, envIndex, to_luastring("cs_eval")); @@ -321,6 +331,13 @@ export const runLua = }); lua.lua_setfield(L, envIndex, to_luastring("move")); + 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, () => { const pin = luaToJs(L, 1) as number; if (pin == 63) { @@ -385,8 +402,8 @@ export const runLua = const statusLoad = lauxlib.luaL_loadstring(L, to_luastring(luaCode)); if (statusLoad !== lua.LUA_OK) { - const error = luaToJs(L, -1) as string; - console.error("Lua load error:", error); + const errorMsg = `Lua load error: ${luaToJs(L, -1)}`; + error(errorMsg); return []; } @@ -395,8 +412,8 @@ export const runLua = const statusCall = lua.lua_pcall(L, 0, lua.LUA_MULTRET, 0); if (statusCall !== lua.LUA_OK) { - const error = luaToJs(L, -1) as string; - console.error("Lua call error:", error); + const errorMsg = `Lua call error: ${luaToJs(L, -1)}`; + error(errorMsg); return []; } return actions; diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts index ff183c4b37..17325c6ea6 100644 --- a/frontend/demo/lua_runner/util.ts +++ b/frontend/demo/lua_runner/util.ts @@ -1,6 +1,8 @@ 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"; export const createRecursiveNotImplemented = ( L: unknown, @@ -128,29 +130,21 @@ export const csToLua = (command: RpcRequestBodyItem): string => { } return `toast("move_absolute ${lKind} is not implemented", "error")`; case "move": - const values = (body || []) - .filter(part => part.kind == "axis_overwrite") - .map(axisOverwrite => { - const { axis, axis_operand } = axisOverwrite.args; - if (axis == "all") { - if (axis_operand.kind == "coordinate") { - const { args } = axis_operand; - return `x=${args.x}, y=${args.y}, z=${args.z}`; - } - } else { - if (axis_operand.kind == "numeric") { - return `${axis}=${axis_operand.args.number}`; - } - if (axis_operand.kind == "coordinate") { - return `${axis}=${axis_operand.args[axis]}`; - } - } - }) - .join(", "); - return `move{${values}}`; + 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(${args.pin_number}, "${mode}", ${args.pin_value})`; + return `write_pin(${pin}, "${mode}", ${args.pin_value})`; case "toggle_pin": return `toggle_pin(${args.pin_number})`; case "lua": diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index e59718249b..b2b43a1880 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -59,7 +59,7 @@ import axios from "axios"; import { success, error, warning, info } from "../../toast/toast"; import { edit, save } from "../../api/crud"; import { DeepPartial } from "../../redux/interfaces"; -import { EmergencyLock, Farmbot } from "farmbot"; +import { EmergencyLock, Execute, Farmbot } from "farmbot"; import { Path } from "../../internal_urls"; import { csToLua, runDemoLuaCode, runDemoSequence } from "../../demo/lua_runner"; @@ -91,6 +91,20 @@ describe("sendRPC()", () => { expect(mockDevice.current.send).not.toHaveBeenCalled(); expect(csToLua).toHaveBeenCalledWith(cmd); }); + + 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()", () => { diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 748231243c..2137499b68 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -85,7 +85,14 @@ const maybeAlertLocked = () => /** Send RPC. */ export function sendRPC(command: RpcRequestBodyItem) { if (forceOnline()) { - runDemoLuaCode(csToLua(command)); + if (command.kind == "execute") { + runDemoSequence( + store.getState().resources.index, + command.args.sequence_id, + command.body); + } else { + runDemoLuaCode(csToLua(command)); + } return; } getDevice() 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..c5150ac936 100644 --- a/frontend/resources/__tests__/selectors_test.ts +++ b/frontend/resources/__tests__/selectors_test.ts @@ -54,6 +54,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/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/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 2534e95482..c2d027ed03 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -30,8 +30,9 @@ 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"; const AnimatedGroup = animated(Group); @@ -95,6 +96,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 diff --git a/frontend/three_d_garden/triangle_functions.ts b/frontend/three_d_garden/triangle_functions.ts new file mode 100644 index 0000000000..1ce0864d72 --- /dev/null +++ b/frontend/three_d_garden/triangle_functions.ts @@ -0,0 +1,53 @@ +export 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; + }; 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, From dc6e730d561b630689054160cebe20d5af0080c7 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 16 Jul 2025 14:33:24 -0700 Subject: [PATCH 14/54] support queueing and track call depth in lua runner --- .../demo/lua_runner/__tests__/index_test.ts | 79 ++++++++++++------- .../demo/lua_runner/__tests__/run_test.ts | 2 +- frontend/demo/lua_runner/actions.ts | 53 +++++++++---- frontend/demo/lua_runner/index.ts | 14 +++- frontend/demo/lua_runner/run.ts | 7 +- 5 files changed, 104 insertions(+), 51 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 4937ba863a..1a1c58714f 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -11,7 +11,6 @@ import { fakeToolSlot, } from "../../../__test_support__/fake_state/resources"; let mockResources = buildResourceIndex([]); -let mockPosition = { x: 0, y: 0, z: 0 }; let mockLocked = false; jest.mock("../../../redux/store", () => ({ store: { @@ -20,7 +19,6 @@ jest.mock("../../../redux/store", () => ({ resources: mockResources, bot: { hardware: { - location_data: { position: mockPosition }, informational_settings: { locked: mockLocked }, }, }, @@ -47,6 +45,7 @@ import { } from ".."; import { TOAST_OPTIONS } from "../../../toast/constants"; import { edit, save } from "../../../api/crud"; +import { setCurrent } from "../actions"; describe("runDemoSequence()", () => { beforeEach(() => { @@ -260,11 +259,11 @@ describe("runDemoSequence()", () => { jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(2); }); it("runs move sequence step", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); const firmwareConfig = fakeFirmwareConfig(); firmwareConfig.body.movement_home_up_z = 0; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); @@ -292,7 +291,7 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 4, z: 6 }, }); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(3); }); it("handles missing variables", () => { @@ -322,7 +321,7 @@ describe("runDemoSequence()", () => { } runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(1); expect(info).not.toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); }); @@ -334,7 +333,7 @@ describe("runDemoSequence()", () => { const ri = buildResourceIndex([sequence]).index; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(1); expect(info).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( "Lua load error: [string \"!\"]:1: unexpected symbol near '!'", @@ -348,7 +347,7 @@ describe("runDemoSequence()", () => { const ri = buildResourceIndex([sequence]).index; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(1); expect(info).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( expect.stringContaining("Lua call error:")); @@ -380,11 +379,35 @@ describe("collectDemoSequenceActions()", () => { sequence2.body.body = [findHome2]; const ri = buildResourceIndex([sequence1, sequence2]).index; - const actions = collectDemoSequenceActions(ri, 1, []); + 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."); }); }); @@ -512,7 +535,7 @@ describe("runDemoLuaCode()", () => { }); it("runs cs_eval", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode(` cs_eval{ kind = "rpc_request", @@ -538,7 +561,7 @@ describe("runDemoLuaCode()", () => { args: { message: "test", message_type: "info" }, }]; mockResources = buildResourceIndex([sequence]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode(` cs_eval{ kind = "rpc_request", @@ -554,7 +577,7 @@ describe("runDemoLuaCode()", () => { }); it("runs cs_eval: no body", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("cs_eval{}"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -638,7 +661,7 @@ describe("runDemoLuaCode()", () => { }); it("runs find_home: all", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_home(\"all\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -649,7 +672,7 @@ describe("runDemoLuaCode()", () => { }); it("runs go_to_home: all", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("go_to_home(\"all\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -660,7 +683,7 @@ describe("runDemoLuaCode()", () => { }); it("runs go_to_home: x", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("go_to_home(\"x\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -671,7 +694,7 @@ describe("runDemoLuaCode()", () => { }); it("runs go_to_home: y", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("go_to_home(\"y\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -686,7 +709,7 @@ describe("runDemoLuaCode()", () => { firmwareConfig.body.movement_axis_nr_steps_x = 500; firmwareConfig.body.movement_home_up_z = 0; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_axis_length(\"x\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -705,7 +728,7 @@ describe("runDemoLuaCode()", () => { firmwareConfig.body.movement_axis_nr_steps_y = 500; firmwareConfig.body.movement_home_up_z = 0; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_axis_length(\"y\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -724,7 +747,7 @@ describe("runDemoLuaCode()", () => { firmwareConfig.body.movement_axis_nr_steps_z = 2500; firmwareConfig.body.movement_home_up_z = 0; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_axis_length(\"z\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -839,7 +862,7 @@ describe("runDemoLuaCode()", () => { runDemoLuaCode("update_device{ mounted_tool_id = 1 }"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(console.log).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledTimes(1); expect(info).not.toHaveBeenCalled(); expect(edit).toHaveBeenCalledWith(device, { mounted_tool_id: 1 }); expect(save).toHaveBeenCalledWith(device.uuid); @@ -877,7 +900,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move_relative", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move_relative(1, 0, 0)"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -888,7 +911,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move_relative: zero", () => { - mockPosition = { x: 0, y: 0, z: 0 }; + setCurrent({ x: 0, y: 0, z: 0 }); runDemoLuaCode("move_relative(0, 0, 0)"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -899,7 +922,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move_absolute", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move_absolute(1, 0, 0)"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -910,7 +933,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move_absolute: alternate syntax", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move_absolute{ x = 1, y = 0, z = 0 }"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -925,7 +948,7 @@ describe("runDemoLuaCode()", () => { firmwareConfig.body.movement_axis_nr_steps_z = 2500; firmwareConfig.body.movement_home_up_z = 0; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move_absolute(0, 0, 1000)"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -940,7 +963,7 @@ describe("runDemoLuaCode()", () => { firmwareConfig.body.movement_axis_nr_steps_z = 2500; firmwareConfig.body.movement_home_up_z = 1; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move_absolute(0, 0, -1000)"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -951,7 +974,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move: y", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move{ y = 1 }"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); @@ -962,7 +985,7 @@ describe("runDemoLuaCode()", () => { }); it("runs move: x and z", () => { - mockPosition = { x: 1, y: 2, z: 3 }; + setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("move{ x = 0, z = 0 }"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); diff --git a/frontend/demo/lua_runner/__tests__/run_test.ts b/frontend/demo/lua_runner/__tests__/run_test.ts index f39a1fca52..9008e4d62c 100644 --- a/frontend/demo/lua_runner/__tests__/run_test.ts +++ b/frontend/demo/lua_runner/__tests__/run_test.ts @@ -8,7 +8,7 @@ describe("runLua()", () => { go_to_home("all") move{ y = 1 } `; - expect(runLua(code, [])).toEqual([ + expect(runLua(0, code, [])).toEqual([ { type: "move_absolute", args: [1, 2, 3] }, { type: "wait_ms", args: [1000] }, { type: "go_to_home", args: ["all"] }, diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 79aa41e31b..89216e54d1 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -4,7 +4,6 @@ import { import { info } from "../../toast/toast"; import { store } from "../../redux/store"; import { Actions } from "../../constants"; -import { validBotLocationData } from "../../util/location"; import { TOAST_OPTIONS } from "../../toast/constants"; import { Action, XyzNumber } from "./interfaces"; import { edit, save } from "../../api/crud"; @@ -69,18 +68,24 @@ const clampTarget = (target: XyzNumber): XyzNumber => { 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; +}; + const expandActions = ( actions: Action[], variables: ParameterApplication[] | undefined, ): Action[] => { const expanded: Action[] = []; - const { position } = validBotLocationData( - store.getState().bot.hardware.location_data); - const current = { - x: position.x as number, - y: position.y as number, - z: position.z as number, - }; const addPosition = (position: XyzNumber) => { expanded.push({ type: "wait_ms", @@ -91,11 +96,6 @@ const expandActions = ( args: [position.x, position.y, position.z], }); }; - const setCurrent = (position: XyzNumber) => { - current.x = position.x; - current.y = position.y; - current.z = position.z; - }; actions.map(action => { switch (action.type) { case "move_absolute": @@ -154,7 +154,12 @@ const expandActions = ( return expanded; }; -const pending = new Set>(); +interface Scheduled { + timeoutId: ReturnType; + timestamp: number; +} +const pending = new Set(); +let latestActionMs = Date.now(); export const runActions = ( actions: Action[], @@ -184,9 +189,12 @@ export const runActions = ( console.log(action.args[0]); }; case "emergency_lock": + delay = 0; + latestActionMs = Date.now(); return () => { - pending.forEach(clearTimeout); + pending.forEach(t => clearTimeout(t.timeoutId)); pending.clear(); + console.log(`Queue length: ${pending.size}`); store.dispatch({ type: Actions.DEMO_SET_ESTOP, payload: true, @@ -259,7 +267,20 @@ export const runActions = ( } }; const func = getFunc(); - func && pending.add(setTimeout(func, delay)); + if (func) { + latestActionMs = Math.max(latestActionMs, Date.now()) + delay; + const funcDelay = latestActionMs - Date.now(); + const timeout = { + timeoutId: setTimeout(() => { + pending.delete(timeout); + console.log(`Queue length: ${pending.size}`); + func(); + }, funcDelay), + timestamp: latestActionMs, + }; + pending.add(timeout); + delay = 0; + } }); }; diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts index 58e8e69f55..a18b6c54e6 100644 --- a/frontend/demo/lua_runner/index.ts +++ b/frontend/demo/lua_runner/index.ts @@ -5,29 +5,37 @@ import { runLua } from "./run"; import { runActions } from "./actions"; import { Action } from "./interfaces"; import { csToLua } from "./util"; +import { error } from "../../toast/toast"; export const runDemoLuaCode = (luaCode: string) => { - const actions = runLua(luaCode, []); + const actions = runLua(0, luaCode, []); runActions(actions, []); }; export const collectDemoSequenceActions = ( + depth: number, resources: ResourceIndex, sequenceId: number, variables: ParameterApplication[] | undefined, ) => { + console.log(`Call depth: ${depth}`); + if (depth > 100) { + error("Maximum call depth exceeded."); + return []; + } const sequence = findSequenceById(resources, sequenceId); const actions: Action[] = []; (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(lua, variables || []); + const stepActions = runLua(depth, lua, variables || []); actions.push(...stepActions); } }); @@ -39,7 +47,7 @@ export const runDemoSequence = ( sequenceId: number, variables: ParameterApplication[] | undefined, ) => { - const actions = collectDemoSequenceActions(resources, sequenceId, variables); + const actions = collectDemoSequenceActions(0, resources, sequenceId, variables); runActions(actions, variables); }; diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index a3cf705bed..d1ed348163 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -17,7 +17,7 @@ import { error } from "../../toast/toast"; import { collectDemoSequenceActions } from "./index"; export const runLua = - (luaCode: string, variables: ParameterApplication[]): Action[] => { + (depth: number, luaCode: string, variables: ParameterApplication[]): Action[] => { const actions: Action[] = []; const L = lauxlib.luaL_newstate(); // stack: [] @@ -167,10 +167,11 @@ export const runLua = const ri = store.getState().resources.index; const sequenceId = cmd.args.sequence_id; const seqVariables = cmd.body; - const seqActions = collectDemoSequenceActions(ri, sequenceId, seqVariables); + const seqActions = collectDemoSequenceActions( + depth + 1, ri, sequenceId, seqVariables); actions.push(...seqActions); } else { - const luaActions = runLua(csToLua(cmd), variables); + const luaActions = runLua(depth, csToLua(cmd), variables); actions.push(...luaActions); } return 0; From 07dceb84b22d81eda6fbb264ad09a7b40d09eb19 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 17 Jul 2025 14:44:15 -0700 Subject: [PATCH 15/54] add point group loops and safe z to demo accounts --- .../devices/seeders/abstract_seeder.rb | 2 +- app/mutations/devices/seeders/constants.rb | 2 +- .../devices/seeders/demo_account_seeder.rb | 2 +- .../devices/seeders/sequence_fixtures.yml | 2 + .../demo/lua_runner/__tests__/actions_test.ts | 158 +++++++++++++++--- .../demo/lua_runner/__tests__/index_test.ts | 36 +++- frontend/demo/lua_runner/actions.ts | 128 ++++++++------ frontend/demo/lua_runner/index.ts | 34 +++- frontend/demo/lua_runner/interfaces.ts | 1 + frontend/demo/lua_runner/stubs.ts | 14 ++ frontend/devices/__tests__/actions_test.ts | 21 ++- frontend/devices/actions.ts | 5 +- frontend/farm_designer/location_info.tsx | 2 +- .../farm_designer/map/group_order_visual.tsx | 15 +- .../map/layers/points/interpolation_map.tsx | 2 +- .../__tests__/other_sort_methods_test.ts | 7 + .../point_groups/__tests__/paths_test.tsx | 3 +- frontend/point_groups/criteria/component.tsx | 4 +- frontend/point_groups/other_sort_methods.ts | 51 ++++++ frontend/point_groups/paths.tsx | 52 +----- frontend/point_groups/point_group_sort.ts | 2 +- .../three_d_garden/bot/components/tools.tsx | 2 +- 22 files changed, 394 insertions(+), 151 deletions(-) create mode 100644 frontend/point_groups/__tests__/other_sort_methods_test.ts create mode 100644 frontend/point_groups/other_sort_methods.ts diff --git a/app/mutations/devices/seeders/abstract_seeder.rb b/app/mutations/devices/seeders/abstract_seeder.rb index c1cd8fb13a..68bea33599 100644 --- a/app/mutations/devices/seeders/abstract_seeder.rb +++ b/app/mutations/devices/seeders/abstract_seeder.rb @@ -306,7 +306,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], diff --git a/app/mutations/devices/seeders/constants.rb b/app/mutations/devices/seeders/constants.rb index ca3ce2bb3b..4db02b774c 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 = -350 + TROUGH_Z = -300 TROUGH_SPACING = 25 module Names diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 6274309622..c544a08656 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -143,7 +143,7 @@ def before_product_line_seeder device .fbos_config .update!( - safe_height: -200, + safe_height: -150, ) end diff --git a/app/mutations/devices/seeders/sequence_fixtures.yml b/app/mutations/devices/seeders/sequence_fixtures.yml index 81de137d36..8e8c3d40b0 100644 --- a/app/mutations/devices/seeders/sequence_fixtures.yml +++ b/app/mutations/devices/seeders/sequence_fixtures.yml @@ -585,6 +585,8 @@ :kind: numeric :args: :number: 150 + - :kind: safe_z + :args: {} :comment: Move above seed bin - :kind: write_pin :args: diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 1dddc05e89..ce20652a62 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -2,9 +2,12 @@ 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", () => ({ @@ -29,22 +32,89 @@ jest.mock("../../../three_d_garden/triangle_functions", () => ({ import { Move, ParameterApplication } from "farmbot"; import { TOAST_OPTIONS } from "../../../toast/constants"; import { info } from "../../../toast/toast"; -import { calculateMove, runActions } from "../actions"; +import { calculateMove, eStop, expandActions, runActions, setCurrent } from "../actions"; describe("runActions()", () => { + beforeEach(() => { + console.log = jest.fn(); + }); + 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(); + }); +}); + +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(), + ]); + }); + + it("chunks movements: default", () => { + expect(expandActions([ + { type: "move_absolute", args: [300, 0, 0] }, + ], [])).toEqual([ + { type: "wait_ms", args: [500] }, + { type: "expanded_move_absolute", args: [100, 0, 0] }, + { type: "wait_ms", args: [500] }, + { type: "expanded_move_absolute", args: [200, 0, 0] }, + { type: "wait_ms", args: [500] }, + { type: "expanded_move_absolute", args: [300, 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] }, + ]); + }); }); 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", @@ -60,7 +130,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 2, y: 2, z: 3 }); + .toEqual([{ x: 2, y: 2, z: 3 }]); }); it("handles number all axis addition", () => { @@ -78,7 +148,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 2, y: 3, z: 4 }); + .toEqual([{ x: 2, y: 3, z: 4 }]); }); it("handles coordinate single axis addition", () => { @@ -96,7 +166,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 2, y: 2, z: 3 }); + .toEqual([{ x: 2, y: 2, z: 3 }]); }); it("handles coordinate all axis addition", () => { @@ -114,7 +184,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 2, y: 4, z: 6 }); + .toEqual([{ x: 2, y: 4, z: 6 }]); }); it("handles number single axis overwrite", () => { @@ -132,7 +202,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 3, y: 2, z: 3 }); + .toEqual([{ x: 3, y: 2, z: 3 }]); }); it("handles number all axis overwrite", () => { @@ -150,7 +220,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual({ x: 1, y: 1, z: 1 }); + .toEqual([{ x: 1, y: 1, z: 1 }]); }); it("handles coordinate single axis overwrite", () => { @@ -168,7 +238,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 1, y: 0, z: 0 }); + .toEqual([{ x: 1, y: 0, z: 0 }]); }); it("handles coordinate all axis overwrite", () => { @@ -186,7 +256,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 1, y: 2, z: 3 }); + .toEqual([{ x: 1, y: 2, z: 3 }]); }); it("handles tool single axis overwrite", () => { @@ -212,7 +282,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 1, y: 0, z: 0 }); + .toEqual([{ x: 1, y: 0, z: 0 }]); }); it("handles tool all axis overwrite", () => { @@ -238,7 +308,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 1, y: 2, z: 3 }); + .toEqual([{ x: 1, y: 2, z: 3 }]); }); it("handles missing tool", () => { @@ -257,7 +327,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 0, y: 0, z: 0 }); + .toEqual([{ x: 0, y: 0, z: 0 }]); }); it("handles coordinate identifier all axis overwrite", () => { @@ -288,7 +358,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual({ x: 1, y: 2, z: 3 }); + .toEqual([{ x: 1, y: 2, z: 3 }]); }); it("handles point identifier all axis overwrite", () => { @@ -324,7 +394,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual({ x: 1, y: 2, z: 3 }); + .toEqual([{ x: 1, y: 2, z: 3 }]); }); it("handles missing point", () => { @@ -355,7 +425,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual({ x: 0, y: 0, z: 0 }); + .toEqual([{ x: 0, y: 0, z: 0 }]); }); it("handles missing variables", () => { @@ -374,7 +444,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, undefined)) - .toEqual({ x: 0, y: 0, z: 0 }); + .toEqual([{ x: 0, y: 0, z: 0 }]); }); it("handles soil height z axis overwrite", () => { @@ -392,7 +462,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 0, y: 0, z: 3 }); + .toEqual([{ x: 0, y: 0, z: 3 }]); }); it("handles soil height z axis overwrite: triangle data", () => { @@ -411,7 +481,30 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 0, y: 0, z: 3 }); + .toEqual([{ x: 0, y: 0, z: 3 }]); + }); + + 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([{ x: 0, y: 0, z: 3 }]); }); it("handles soil height z axis overwrite: wrong label", () => { @@ -429,12 +522,35 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 0, y: 0, z: 0 }); + .toEqual([{ x: 0, y: 0, z: 0 }]); + }); + + 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: "safe_z", args: {} }, + ], + }; + expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) + .toEqual([ + { x: 50, y: 50, z: 0 }, + { x: 100, y: 100, z: 0 }, + { x: 100, y: 100, z: 100 }, + ]); }); it("handles missing body", () => { const command: Move = { kind: "move", args: {} }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ x: 0, y: 0, z: 0 }); + .toEqual([{ x: 0, y: 0, z: 0 }]); }); }); diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 1a1c58714f..004a652760 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -9,6 +9,7 @@ import { fakePoint, fakeSequence, fakeTool, fakeToolSlot, + fakePointGroup, } from "../../../__test_support__/fake_state/resources"; let mockResources = buildResourceIndex([]); let mockLocked = false; @@ -223,6 +224,39 @@ describe("runDemoSequence()", () => { 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).toHaveBeenCalledTimes(3); + expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); + }); + it("runs sequence with other variable", () => { const sequence = fakeSequence(); sequence.body.body = [{ @@ -291,7 +325,7 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 4, z: 6 }, }); - expect(console.log).toHaveBeenCalledTimes(3); + expect(console.log).toHaveBeenCalledTimes(2); }); it("handles missing variables", () => { diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 89216e54d1..ec7d5a1ee9 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -13,9 +13,10 @@ import { maybeFindSlotByToolId, } from "../../resources/selectors"; import { UnknownAction } from "redux"; -import { getFirmwareSettings, getGardenSize } from "./stubs"; +import { getFirmwareSettings, getGardenSize, getSafeZ } from "./stubs"; import { clamp, clone } from "lodash"; import { getZFunc, TriangleData } from "../../three_d_garden/triangle_functions"; +import { validBotLocationData } from "../../util/location"; const almostEqual = (a: XyzNumber, b: XyzNumber) => { const epsilon = 0.01; @@ -27,6 +28,7 @@ const almostEqual = (a: XyzNumber, b: XyzNumber) => { const movementChunks = ( current: XyzNumber, target: XyzNumber, + mmPerTimeStep: number, ): XyzNumber[] => { const dx = target.x - current.x; const dy = target.y - current.y; @@ -39,17 +41,17 @@ const movementChunks = ( y: dy / length, z: dz / length, }; - const steps = Math.floor(length / 100); + const steps = Math.floor(length / mmPerTimeStep); const chunks: XyzNumber[] = []; - for (let i = 0; i <= steps; i++) { + for (let i = 1; i <= steps; i++) { const step = { - x: current.x + direction.x * 100 * i, - y: current.y + direction.y * 100 * i, - z: current.z + direction.z * 100 * i, + 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 (!almostEqual(chunks[chunks.length - 1], target)) { + if (chunks.length === 0 || !almostEqual(chunks[chunks.length - 1], target)) { chunks.push(target); } return chunks; @@ -81,18 +83,21 @@ export const setCurrent = (position: XyzNumber) => { current.z = position.z; }; -const expandActions = ( +export const expandActions = ( actions: Action[], variables: ParameterApplication[] | undefined, ): Action[] => { const expanded: Action[] = []; + const timeStepMs = parseInt(localStorage.getItem("timeStepMs") || "500"); + const mmPerSecond = parseInt(localStorage.getItem("mmPerSecond") || "200"); + const mmPerTimeStep = (mmPerSecond * timeStepMs) / 1000; const addPosition = (position: XyzNumber) => { expanded.push({ type: "wait_ms", - args: [500], + args: [timeStepMs], }); expanded.push({ - type: "move_absolute", + type: "expanded_move_absolute", args: [position.x, position.y, position.z], }); }; @@ -104,7 +109,7 @@ const expandActions = ( y: action.args[1] as number, z: action.args[2] as number, }); - movementChunks(current, moveAbsoluteTarget).map(addPosition); + movementChunks(current, moveAbsoluteTarget, mmPerTimeStep).map(addPosition); setCurrent(moveAbsoluteTarget); break; case "move_relative": @@ -113,15 +118,17 @@ const expandActions = ( y: current.y + (action.args[1] as number), z: current.z + (action.args[2] as number), }); - movementChunks(current, moveRelativeTarget).map(addPosition); + movementChunks(current, moveRelativeTarget, mmPerTimeStep).map(addPosition); setCurrent(moveRelativeTarget); break; case "_move": const moveItems = JSON.parse("" + action.args[0]) as MoveBodyItem[]; - const actualMoveTarget = clampTarget( - calculateMove(moveItems, current, variables)); - movementChunks(current, actualMoveTarget).map(addPosition); - setCurrent(actualMoveTarget); + const moves = calculateMove(moveItems, current, variables); + const actualMoveTargets = moves.map(clampTarget); + actualMoveTargets.map(actualMoveTarget => { + movementChunks(current, actualMoveTarget, mmPerTimeStep).map(addPosition); + setCurrent(actualMoveTarget); + }); break; case "move": const moveTarget = clampTarget({ @@ -129,7 +136,7 @@ const expandActions = ( y: (action.args[1] as number | undefined) ?? current.y, z: (action.args[2] as number | undefined) ?? current.z, }); - movementChunks(current, moveTarget).map(addPosition); + movementChunks(current, moveTarget, mmPerTimeStep).map(addPosition); setCurrent(moveTarget); break; case "find_home": @@ -142,7 +149,7 @@ const expandActions = ( y: axis == "y" ? 0 : current.y, z: axis == "z" ? 0 : current.z, }; - movementChunks(current, homeTarget).map(addPosition); + movementChunks(current, homeTarget, mmPerTimeStep).map(addPosition); setCurrent(homeTarget); }); break; @@ -155,18 +162,33 @@ const expandActions = ( }; interface Scheduled { - timeoutId: ReturnType; + func(): void; timestamp: number; } -const pending = new Set(); +const pending: Scheduled[] = []; let latestActionMs = Date.now(); +let currentTimer: ReturnType | undefined = undefined; + +export const eStop = () => { + latestActionMs = 0; + pending.length = 0; + console.log(`Queue length: ${pending.length}`); + 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[], - variables: ParameterApplication[] | undefined, ) => { let delay = 0; - expandActions(actions, variables).map(action => { + actions.map(action => { // eslint-disable-next-line complexity const getFunc = () => { const estopped = store.getState().bot.hardware.informational_settings.locked; @@ -189,17 +211,7 @@ export const runActions = ( console.log(action.args[0]); }; case "emergency_lock": - delay = 0; - latestActionMs = Date.now(); - return () => { - pending.forEach(t => clearTimeout(t.timeoutId)); - pending.clear(); - console.log(`Queue length: ${pending.size}`); - store.dispatch({ - type: Actions.DEMO_SET_ESTOP, - payload: true, - }); - }; + return eStop; case "emergency_unlock": return () => { store.dispatch({ @@ -207,7 +219,7 @@ export const runActions = ( payload: false, }); }; - case "move_absolute": + 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; @@ -269,29 +281,39 @@ export const runActions = ( const func = getFunc(); if (func) { latestActionMs = Math.max(latestActionMs, Date.now()) + delay; - const funcDelay = latestActionMs - Date.now(); - const timeout = { - timeoutId: setTimeout(() => { - pending.delete(timeout); - console.log(`Queue length: ${pending.size}`); - func(); - }, funcDelay), - timestamp: latestActionMs, - }; - pending.add(timeout); + 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(); + console.log(`Queue length: ${pending.length}`); + runNext(); + }, delay); +}; + export const calculateMove = ( body: MoveBodyItem[] | undefined, current: XyzNumber, variables: ParameterApplication[] | undefined, -) => { +): XyzNumber[] => { const pos = clone(current); + const moveBodyItems = body || []; // eslint-disable-next-line complexity - (body || []).map(item => { + moveBodyItems.map(item => { switch (item.kind) { case "axis_addition": switch (item.args.axis_operand.kind) { @@ -379,10 +401,22 @@ export const calculateMove = ( const getZ = getZFunc(triangles, -500); pos.z = getZ(pos.x, pos.y); } + if (item.args.axis_operand.args.label == "safe_height" + && item.args.axis == "z") { + pos.z = getSafeZ(); + } break; } return; } }); - return pos; + if (moveBodyItems.some(item => item.kind === "safe_z")) { + const safeZ = getSafeZ(); + return [ + { x: current.x, y: current.y, z: safeZ }, + { x: pos.x, y: pos.y, z: safeZ }, + pos, + ]; + } + return [pos]; }; diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts index a18b6c54e6..ebbf3da857 100644 --- a/frontend/demo/lua_runner/index.ts +++ b/frontend/demo/lua_runner/index.ts @@ -1,15 +1,16 @@ import { findSequenceById } from "../../resources/selectors"; import { ResourceIndex } from "../../resources/interfaces"; -import { ParameterApplication, SequenceBodyItem } from "farmbot"; +import { ParameterApplication, Point, SequenceBodyItem } from "farmbot"; import { runLua } from "./run"; -import { runActions } from "./actions"; +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(actions, []); + runActions(expandActions(actions, [])); }; export const collectDemoSequenceActions = ( @@ -17,7 +18,7 @@ export const collectDemoSequenceActions = ( resources: ResourceIndex, sequenceId: number, variables: ParameterApplication[] | undefined, -) => { +): Action[] => { console.log(`Call depth: ${depth}`); if (depth > 100) { error("Maximum call depth exceeded."); @@ -25,6 +26,29 @@ export const collectDemoSequenceActions = ( } const sequence = findSequenceById(resources, sequenceId); const actions: Action[] = []; + if (variables?.[0]?.args.data_value.kind == "point_group") { + const variableLabel = variables[0].args.label; + const groupId = variables[0].args.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( @@ -48,7 +72,7 @@ export const runDemoSequence = ( variables: ParameterApplication[] | undefined, ) => { const actions = collectDemoSequenceActions(0, resources, sequenceId, variables); - runActions(actions, variables); + runActions(expandActions(actions, variables)); }; export { csToLua }; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index 460181d904..199215a8ea 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -3,6 +3,7 @@ import { Xyz } from "farmbot"; export interface Action { type: | "move_absolute" + | "expanded_move_absolute" | "move_relative" | "move" | "_move" diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index 55cb95cd1c..de070b322c 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -10,6 +10,12 @@ 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"; export const getFirmwareSettings = (): FirmwareConfig => { const fwConfig = getFirmwareConfig(store.getState().resources.index); @@ -44,3 +50,11 @@ export const getSafeZ = (): number => { const fbosSettings = getFbosSettings(); return fbosSettings.safe_height || 0; }; + +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); +}; diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index b2b43a1880..7dafa78301 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -49,6 +49,10 @@ jest.mock("../../demo/lua_runner", () => ({ csToLua: jest.fn(), })); +jest.mock("../../demo/lua_runner/actions", () => ({ + eStop: jest.fn(), +})); + import * as actions from "../actions"; import { fakeFirmwareConfig, fakeFbosConfig, @@ -59,9 +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 { EmergencyLock, Execute, 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(); @@ -86,12 +91,21 @@ describe("sendRPC()", () => { it("calls sendRPC on demo accounts", async () => { localStorage.setItem("myBotIs", "online"); - const cmd: EmergencyLock = { kind: "emergency_lock", args: {} }; + 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: [] }; @@ -190,7 +204,8 @@ describe("emergencyLock() / emergencyUnlock", () => { localStorage.setItem("myBotIs", "online"); actions.emergencyLock(); expect(mockDevice.current.emergencyLock).not.toHaveBeenCalled(); - expect(runDemoLuaCode).toHaveBeenCalledWith("emergency_lock()"); + expect(runDemoLuaCode).not.toHaveBeenCalled(); + expect(eStop).toHaveBeenCalled(); }); it("calls emergencyUnlock", () => { diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 2137499b68..3109b13427 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -37,6 +37,7 @@ 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; @@ -90,6 +91,8 @@ export function sendRPC(command: RpcRequestBodyItem) { store.getState().resources.index, command.args.sequence_id, command.body); + } else if (command.kind == "emergency_lock") { + eStop(); } else { runDemoLuaCode(csToLua(command)); } @@ -173,7 +176,7 @@ export function flashFirmware(firmwareName: FirmwareHardware) { export function emergencyLock() { const noun = t("Emergency stop"); if (forceOnline()) { - runDemoLuaCode("emergency_lock()"); + eStop(); return; } getDevice() diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index b550716af9..bcdb99b7ae 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"; diff --git a/frontend/farm_designer/map/group_order_visual.tsx b/frontend/farm_designer/map/group_order_visual.tsx index 764eed257e..c075ed65a7 100644 --- a/frontend/farm_designer/map/group_order_visual.tsx +++ b/frontend/farm_designer/map/group_order_visual.tsx @@ -4,9 +4,7 @@ import { isUndefined } from "lodash"; import { sortGroupBy } from "../../point_groups/point_group_sort"; import { Color } from "../../ui"; import { transformXY } from "./util"; -import { - ExtendedPointGroupSortType, convertToXY, -} from "../../point_groups/paths"; +import { convertToXY } from "../../point_groups/paths"; import { TaggedPoint, TaggedPointGroup } from "farmbot"; import { zoomCompensation } from "./zoom"; import { equals } from "../../util"; @@ -20,15 +18,6 @@ export interface GroupOrderProps { tryGroupSortType: PointGroupSortType | undefined; } -export const sortGroup = ( - groupSortType: ExtendedPointGroupSortType, - groupPoints: TaggedPoint[], -) => { - 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/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/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 52d41c2b7b..881547dcb8 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -342,7 +342,7 @@ export const Tools = (props: ToolsProps) => { position={[ position.x - 30, position.y - 15, - position.z, + position.z - 40, ]} rotation={[0, 0, Math.PI / 2]}> {toolProps.firstTrough From 3163ffb99588d5bcc7ab9c29f2fa16c958909ae2 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 18 Jul 2025 22:30:34 -0700 Subject: [PATCH 16/54] add warnings, flowrate, bugfixes, and points to demo accounts --- .../devices/seeders/demo_account_seeder.rb | 10 +- .../demo/lua_runner/__tests__/actions_test.ts | 136 ++++++++++++++---- .../demo/lua_runner/__tests__/index_test.ts | 56 +++++++- frontend/demo/lua_runner/actions.ts | 70 ++++++--- frontend/demo/lua_runner/interfaces.ts | 1 + frontend/demo/lua_runner/run.ts | 38 ++++- frontend/demo/lua_runner/stubs.ts | 8 ++ frontend/tools/add_tool.tsx | 6 +- frontend/tools/edit_tool.tsx | 8 +- .../devices/devices_controller_seed_spec.rb | 6 + 10 files changed, 268 insertions(+), 71 deletions(-) diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index c544a08656..c2921ed2fd 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -20,9 +20,11 @@ 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_plants(product_line) @@ -152,6 +154,8 @@ 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 marketing_bulletin device.alerts.where(problem_tag: UNUSED_ALERTS).destroy_all diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index ce20652a62..0ae5b9c849 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -29,10 +29,14 @@ jest.mock("../../../three_d_garden/triangle_functions", () => ({ getZFunc: jest.fn(() => () => 3), })); -import { Move, ParameterApplication } from "farmbot"; +import { + AxisAddition, AxisOverwrite, Move, MoveBodyItem, ParameterApplication, +} from "farmbot"; import { TOAST_OPTIONS } from "../../../toast/constants"; import { info } from "../../../toast/toast"; -import { calculateMove, eStop, expandActions, runActions, setCurrent } from "../actions"; +import { + calculateMove, eStop, expandActions, runActions, setCurrent, +} from "../actions"; describe("runActions()", () => { beforeEach(() => { @@ -130,7 +134,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 2, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 2, y: 2, z: 3 }], warnings: [] }); }); it("handles number all axis addition", () => { @@ -148,7 +152,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 2, y: 3, z: 4 }]); + .toEqual({ moves: [{ x: 2, y: 3, z: 4 }], warnings: [] }); }); it("handles coordinate single axis addition", () => { @@ -166,7 +170,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 2, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 2, y: 2, z: 3 }], warnings: [] }); }); it("handles coordinate all axis addition", () => { @@ -184,7 +188,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 2, y: 4, z: 6 }]); + .toEqual({ moves: [{ x: 2, y: 4, z: 6 }], warnings: [] }); }); it("handles number single axis overwrite", () => { @@ -202,7 +206,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 3, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 3, y: 2, z: 3 }], warnings: [] }); }); it("handles number all axis overwrite", () => { @@ -220,7 +224,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 1, y: 2, z: 3 }, [])) - .toEqual([{ x: 1, y: 1, z: 1 }]); + .toEqual({ moves: [{ x: 1, y: 1, z: 1 }], warnings: [] }); }); it("handles coordinate single axis overwrite", () => { @@ -238,7 +242,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 1, y: 0, z: 0 }]); + .toEqual({ moves: [{ x: 1, y: 0, z: 0 }], warnings: [] }); }); it("handles coordinate all axis overwrite", () => { @@ -256,7 +260,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 1, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); }); it("handles tool single axis overwrite", () => { @@ -282,7 +286,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 1, y: 0, z: 0 }]); + .toEqual({ moves: [{ x: 1, y: 0, z: 0 }], warnings: [] }); }); it("handles tool all axis overwrite", () => { @@ -308,7 +312,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 1, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); }); it("handles missing tool", () => { @@ -327,7 +331,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 0, y: 0, z: 0 }]); + .toEqual({ moves: [{ x: 0, y: 0, z: 0 }], warnings: [] }); }); it("handles coordinate identifier all axis overwrite", () => { @@ -358,7 +362,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual([{ x: 1, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); }); it("handles point identifier all axis overwrite", () => { @@ -394,7 +398,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual([{ x: 1, y: 2, z: 3 }]); + .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); }); it("handles missing point", () => { @@ -425,7 +429,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, variables)) - .toEqual([{ x: 0, y: 0, z: 0 }]); + .toEqual({ moves: [{ x: 0, y: 0, z: 0 }], warnings: [] }); }); it("handles missing variables", () => { @@ -444,7 +448,10 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, undefined)) - .toEqual([{ x: 0, y: 0, z: 0 }]); + .toEqual({ + moves: [{ x: 0, y: 0, z: 0 }], + warnings: ["identifier location kind: undefined"], + }); }); it("handles soil height z axis overwrite", () => { @@ -462,7 +469,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 0, y: 0, z: 3 }]); + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); }); it("handles soil height z axis overwrite: triangle data", () => { @@ -481,7 +488,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 0, y: 0, z: 3 }]); + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); }); it("handles safe height z axis overwrite", () => { @@ -504,7 +511,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 0, y: 0, z: 3 }]); + .toEqual({ moves: [{ x: 0, y: 0, z: 3 }], warnings: [] }); }); it("handles soil height z axis overwrite: wrong label", () => { @@ -522,7 +529,10 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual([{ x: 0, y: 0, z: 0 }]); + .toEqual({ + moves: [{ x: 0, y: 0, z: 0 }], + warnings: ["special_value label: nope"], + }); }); it("handles safe_z", () => { @@ -537,20 +547,92 @@ describe("calculateMove()", () => { 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([ - { x: 50, y: 50, z: 0 }, - { x: 100, y: 100, z: 0 }, - { x: 100, y: 100, z: 100 }, - ]); + .toEqual({ + moves: [ + { x: 50, y: 50, z: 0 }, + { x: 100, y: 100, z: 0 }, + { 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([{ 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 index 004a652760..5d3aa68ac9 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -30,6 +30,7 @@ jest.mock("../../../redux/store", () => ({ jest.mock("../../../api/crud", () => ({ edit: jest.fn(), save: jest.fn(), + initSave: jest.fn(), })); import { @@ -45,7 +46,7 @@ import { runDemoSequence, } from ".."; import { TOAST_OPTIONS } from "../../../toast/constants"; -import { edit, save } from "../../../api/crud"; +import { edit, initSave, save } from "../../../api/crud"; import { setCurrent } from "../actions"; describe("runDemoSequence()", () => { @@ -325,7 +326,7 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 4, z: 6 }, }); - expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledTimes(3); }); it("handles missing variables", () => { @@ -533,6 +534,47 @@ describe("runDemoLuaCode()", () => { 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(1); + 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(1); + 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; @@ -1106,9 +1148,9 @@ describe("csToLua()", () => { * [ y ] current_day * * FBOS: - * [ y ] variable (numeric/text only) + * [ y ] variable * [ ] auth_token - * [ y ] api (GET /api/points only) + * [ y ] api * [ ] base64.decode * [ ] base64.encode * [ ] calibrate_camera @@ -1159,9 +1201,9 @@ describe("csToLua()", () => { * [ ] read_status * [ y ] rpc * [ y ] sequence - * [ y ] send_message (info only) + * [ y ] send_message * [ y ] debug - * [ y ] toast (info only) + * [ y ] toast * [ y ] safe_z * [ ] set_job * [ y ] set_job_progress @@ -1187,5 +1229,5 @@ describe("csToLua()", () => { * [ ] watch_pin * [ y ] on * [ y ] off - * [ y ] write_pin (digital only) + * [ y ] write_pin */ diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index ec7d5a1ee9..1162e9cf9a 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -6,17 +6,19 @@ import { store } from "../../redux/store"; import { Actions } from "../../constants"; import { TOAST_OPTIONS } from "../../toast/constants"; import { Action, XyzNumber } from "./interfaces"; -import { edit, save } from "../../api/crud"; +import { edit, initSave, save } from "../../api/crud"; import { getDeviceAccountSettings, maybeFindPointById, maybeFindSlotByToolId, } from "../../resources/selectors"; import { UnknownAction } from "redux"; -import { getFirmwareSettings, getGardenSize, getSafeZ } from "./stubs"; +import { + getFirmwareSettings, getGardenSize, getSafeZ, getSoilHeight, +} from "./stubs"; import { clamp, clone } from "lodash"; -import { getZFunc, TriangleData } from "../../three_d_garden/triangle_functions"; import { validBotLocationData } from "../../util/location"; +import { Point } from "farmbot/dist/resources/api_resources"; const almostEqual = (a: XyzNumber, b: XyzNumber) => { const epsilon = 0.01; @@ -76,7 +78,6 @@ const current = { z: 0, }; - export const setCurrent = (position: XyzNumber) => { current.x = position.x; current.y = position.y; @@ -123,7 +124,11 @@ export const expandActions = ( break; case "_move": const moveItems = JSON.parse("" + action.args[0]) as MoveBodyItem[]; - const moves = calculateMove(moveItems, current, variables); + const { moves, warnings } = calculateMove(moveItems, current, variables); + expanded.push({ + type: "send_message", + args: ["warn", `not yet supported: ${warnings.join(", ")}`], + }); const actualMoveTargets = moves.map(clampTarget); actualMoveTargets.map(actualMoveTarget => { movementChunks(current, actualMoveTarget, mmPerTimeStep).map(addPosition); @@ -267,6 +272,12 @@ export const runActions = ( 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 = @@ -309,8 +320,9 @@ export const calculateMove = ( body: MoveBodyItem[] | undefined, current: XyzNumber, variables: ParameterApplication[] | undefined, -): XyzNumber[] => { +): { moves: XyzNumber[], warnings: string[] } => { const pos = clone(current); + const warnings: string[] = []; const moveBodyItems = body || []; // eslint-disable-next-line complexity moveBodyItems.map(item => { @@ -335,6 +347,10 @@ export const calculateMove = ( 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": @@ -382,8 +398,7 @@ export const calculateMove = ( pos.x = location.args.x; pos.y = location.args.y; pos.z = location.args.z; - } - if (location?.kind == "point") { + } else if (location?.kind == "point") { const point = maybeFindPointById( store.getState().resources.index, location.args.pointer_id); @@ -391,32 +406,47 @@ export const calculateMove = ( 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") { - const triangles = JSON.parse( - sessionStorage.getItem("triangles") || "[]") as TriangleData[]; - const getZ = getZFunc(triangles, -500); - pos.z = getZ(pos.x, pos.y); - } - if (item.args.axis_operand.args.label == "safe_height" + 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; + default: + warnings.push(`item kind: ${(item as MoveBodyItem).kind}`); + return; } }); if (moveBodyItems.some(item => item.kind === "safe_z")) { const safeZ = getSafeZ(); - return [ - { x: current.x, y: current.y, z: safeZ }, - { x: pos.x, y: pos.y, z: safeZ }, - pos, - ]; + return { + moves: [ + { x: current.x, y: current.y, z: safeZ }, + { x: pos.x, y: pos.y, z: safeZ }, + pos, + ], + warnings, + }; } - return [pos]; + return { moves: [pos], warnings }; }; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index 199215a8ea..65b9006a44 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -14,6 +14,7 @@ export interface Action { | "go_to_home" | "send_message" | "update_device" + | "create_point" | "print" | "wait_ms" | "write_pin" diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index d1ed348163..af5d414ae2 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -12,7 +12,7 @@ import { LUA_HELPERS } from "./lua"; import { createRecursiveNotImplemented, csToLua, jsToLua, luaToJs } from "./util"; import { Action, XyzNumber } from "./interfaces"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; -import { getGardenSize, getSafeZ } from "./stubs"; +import { getGardenSize, getSafeZ, getSoilHeight } from "./stubs"; import { error } from "../../toast/toast"; import { collectDemoSequenceActions } from "./index"; @@ -117,6 +117,16 @@ export const runLua = 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")); @@ -125,9 +135,10 @@ export const runLua = lua.lua_pushjsfunction(L, () => { lua.lua_getfield(L, 1, to_luastring("method")); - const method = lua.lua_isnil(L, -1) + 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")); @@ -135,11 +146,22 @@ export const runLua = const url = rawUrl.replace(/\/$/, ""); lua.lua_pop(L, 1); - if (method == "GET" && url == "/api/points") { + if (url == "/api/points") { const points = selectAllPoints(store.getState().resources.index); - const results = sortGroupBy("yx_alternating", points).map(p => p.body); - jsToLua(L, results); - return 1; + if (method == "GET") { + const results = sortGroupBy("yx_alternating", points).map(p => p.body); + 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); @@ -320,7 +342,9 @@ export const runLua = lua.lua_setfield(L, envIndex, to_luastring("env")); lua.lua_pushjsfunction(L, () => { - jsToLua(L, -500); + 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")); diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index de070b322c..6ec739d75f 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -16,6 +16,7 @@ import { 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); @@ -51,6 +52,13 @@ export const getSafeZ = (): number => { 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) diff --git a/frontend/tools/add_tool.tsx b/frontend/tools/add_tool.tsx index a41e9dbc7b..ac09dc996e 100644 --- a/frontend/tools/add_tool.tsx +++ b/frontend/tools/add_tool.tsx @@ -199,10 +199,10 @@ export class RawAddTool extends React.Component { name="toolName" onChange={e => this.setState({ toolName: e.currentTarget.value })} /> - {reduceToolName(toolName) == ToolName.wateringNozzle && - } + {reduceToolName(toolName) == ToolName.wateringNozzle && + }

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

diff --git a/frontend/tools/edit_tool.tsx b/frontend/tools/edit_tool.tsx index 1ff41fa958..bf843dacf1 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 && } @@ -143,10 +143,10 @@ export class RawEditTool extends React.Component { this.setState({ toolName: e.currentTarget.value })} /> - {reduceToolName(toolName) == ToolName.wateringNozzle && - }
+ {reduceToolName(toolName) == ToolName.wateringNozzle && + }

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

diff --git a/spec/controllers/api/devices/devices_controller_seed_spec.rb b/spec/controllers/api/devices/devices_controller_seed_spec.rb index dbad44437f..20491caf39 100644 --- a/spec/controllers/api/devices/devices_controller_seed_spec.rb +++ b/spec/controllers/api/devices/devices_controller_seed_spec.rb @@ -1677,6 +1677,12 @@ def check_slot_pairing(slot, expected_name) expect(sequences_grid?(device)).to be_kind_of(Sequence) end + it "seeds accounts with demo account data when tools not available" do + start_tests "none", false, true + + expect(tools_watering_nozzle?(device)).to_not be + end + it "seeds accounts when sequence versions not available: Genesis XL 1.6" do start_tests "genesis_xl_1.6", false From 87ee6cc89fde752d9aa6e5bf5abc4d6e1cb158c8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 19 Jul 2025 15:40:43 -0700 Subject: [PATCH 17/54] support demo sequence scope declarations --- .../demo/lua_runner/__tests__/actions_test.ts | 30 ++++++-- .../demo/lua_runner/__tests__/index_test.ts | 72 ++++++++++++++++++- frontend/demo/lua_runner/actions.ts | 6 +- frontend/demo/lua_runner/index.ts | 21 ++++-- .../sequence_editor_middle_active_test.tsx | 1 - .../sequences/locals_list/locals_list.tsx | 8 +-- .../locals_list/locals_list_support.ts | 4 ++ .../sequences/locals_list/variable_form.tsx | 9 ++- .../sequences/locals_list/variable_support.ts | 8 ++- .../sequences/step_tiles/tile_execute.tsx | 6 +- 10 files changed, 138 insertions(+), 27 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 0ae5b9c849..055418cd12 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -85,15 +85,24 @@ describe("expandActions()", () => { expect(expandActions([ { type: "move_absolute", args: [300, 0, 0] }, ], [])).toEqual([ - { type: "wait_ms", args: [500] }, - { type: "expanded_move_absolute", args: [100, 0, 0] }, - { type: "wait_ms", args: [500] }, - { type: "expanded_move_absolute", args: [200, 0, 0] }, - { type: "wait_ms", args: [500] }, + { 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"); @@ -104,6 +113,17 @@ describe("expandActions()", () => { { type: "expanded_move_absolute", args: [300, 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"] }, + + { type: "wait_ms", args: [250] }, + { type: "expanded_move_absolute", args: [0, 0, 0] }, + ]); + }); }); describe("calculateMove()", () => { diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 5d3aa68ac9..49ab0a4b6d 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -326,7 +326,76 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 4, z: 6 }, }); - expect(console.log).toHaveBeenCalledTimes(3); + expect(console.log).toHaveBeenCalledTimes(2); + }); + + 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(2); + 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(2); + 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).toHaveBeenCalledWith( + "Variable \"Number\" of type undefined not implemented.", + TOAST_OPTIONS().error); + expect(console.log).toHaveBeenCalledWith("undefined"); + expect(error).not.toHaveBeenCalled(); }); it("handles missing variables", () => { @@ -337,6 +406,7 @@ describe("runDemoSequence()", () => { }]; sequence.body.id = 1; const ri = buildResourceIndex([sequence]).index; + ri.sequenceMetas = {}; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(info).toHaveBeenCalledWith( diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 1162e9cf9a..b7b9a3df8c 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -89,8 +89,8 @@ export const expandActions = ( variables: ParameterApplication[] | undefined, ): Action[] => { const expanded: Action[] = []; - const timeStepMs = parseInt(localStorage.getItem("timeStepMs") || "500"); - const mmPerSecond = parseInt(localStorage.getItem("mmPerSecond") || "200"); + 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({ @@ -125,7 +125,7 @@ export const expandActions = ( case "_move": const moveItems = JSON.parse("" + action.args[0]) as MoveBodyItem[]; const { moves, warnings } = calculateMove(moveItems, current, variables); - expanded.push({ + warnings.length > 0 && expanded.push({ type: "send_message", args: ["warn", `not yet supported: ${warnings.join(", ")}`], }); diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts index ebbf3da857..394742ed0d 100644 --- a/frontend/demo/lua_runner/index.ts +++ b/frontend/demo/lua_runner/index.ts @@ -17,7 +17,7 @@ export const collectDemoSequenceActions = ( depth: number, resources: ResourceIndex, sequenceId: number, - variables: ParameterApplication[] | undefined, + bodyVariables: ParameterApplication[] | undefined, ): Action[] => { console.log(`Call depth: ${depth}`); if (depth > 100) { @@ -25,10 +25,21 @@ export const collectDemoSequenceActions = ( 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[] = []; - if (variables?.[0]?.args.data_value.kind == "point_group") { - const variableLabel = variables[0].args.label; - const groupId = variables[0].args.data_value.args.point_group_id; + 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: { @@ -59,7 +70,7 @@ export const collectDemoSequenceActions = ( actions.push(...seqActions); } else { const lua = step.kind === "lua" ? step.args.lua : csToLua(step); - const stepActions = runLua(depth, lua, variables || []); + const stepActions = runLua(depth, lua, variables); actions.push(...stepActions); } }); diff --git a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx index ba2a2b629f..72e5c83007 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(), })); 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/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. */ From 64c91c9719d29b06d153433c252fe32c2fdcbd9e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 19 Jul 2025 15:40:56 -0700 Subject: [PATCH 18/54] improve watering animation --- .../three_d_garden/bot/__tests__/bot_test.tsx | 10 ++++++- frontend/three_d_garden/bot/bot.tsx | 6 ++++ .../bot/components/__tests__/tools_test.tsx | 11 ------- .../__tests__/watering_animations_test.tsx | 4 ++- .../three_d_garden/bot/components/tools.tsx | 6 ---- .../bot/components/watering_animations.tsx | 29 ++++++++++++------- 6 files changed, 37 insertions(+), 29 deletions(-) diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 66a0f9e6d5..e61a00a8d0 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"; @@ -56,9 +57,16 @@ describe("", () => { expect(wrapper.find({ name: "button-group" }).length).toEqual(9); // 3 * 3 }); + it("renders watering animation", () => { + const p = fakeProps(); + p.config.waterFlow = true; + const { container } = render(); + 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 e14a9c87ec..b70697e3b1 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -32,6 +32,7 @@ import { GantryBeam, } from "./components"; import { SlotWithTool } from "../../resources/interfaces"; +import { WateringAnimations } from "./components/watering_animations"; export const extrusionWidth = 20; const utmRadius = 35; @@ -649,6 +650,11 @@ export const Bot = (props: FarmbotModelProps) => { getZ={props.getZ} toolSlots={props.toolSlots} mountedToolName={props.mountedToolName} /> + {config.waterFlow && + } 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..c9d67a2ddf 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,11 +3,13 @@ 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, }); diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 881547dcb8..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"; @@ -261,11 +260,6 @@ export const Tools = (props: ToolsProps) => { scale={1000} geometry={wateringNozzle.nodes[PartName.wateringNozzle].geometry} material={wateringNozzle.materials.PaletteMaterial001} /> - {!inToolbay && props.config.waterFlow && - } ; case ToolName.seedBin: return diff --git a/frontend/three_d_garden/bot/components/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx index f55db9b02b..70e3e26fae 100644 --- a/frontend/three_d_garden/bot/components/watering_animations.tsx +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -4,26 +4,35 @@ 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); + 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; - return + return {range(16).map(i => { const angle = (i * Math.PI * 2) / 16; return { { Date: Thu, 24 Jul 2025 14:16:54 -0700 Subject: [PATCH 19/54] add axis_order --- app/models/celery_script_settings_bag.rb | 6 ++ app/mutations/sequences/publish.rb | 4 +- .../__test_support__/fake_state/resources.ts | 1 + .../resource_index_builder.ts | 2 + frontend/constants.ts | 8 +- .../demo/lua_runner/__tests__/actions_test.ts | 75 +++++++++++++++++++ frontend/demo/lua_runner/actions.ts | 27 +++++++ .../farm_designer/__tests__/move_to_test.tsx | 20 +++-- frontend/farm_designer/move_to.tsx | 19 ++++- .../resources/__tests__/selectors_test.ts | 1 + .../__tests__/axis_order_test.tsx | 62 +++++++++++++++ .../__tests__/component_test.tsx | 15 +++- .../__tests__/safe_z_test.tsx | 17 +---- .../tile_computed_move/axis_order.tsx | 56 ++++++++++++++ .../tile_computed_move/component.tsx | 25 +++++-- .../tile_computed_move/interfaces.ts | 13 ++-- .../step_tiles/tile_computed_move/safe_z.tsx | 17 ----- .../tile_computed_move/test_fixtures.ts | 2 + package.json | 2 +- 19 files changed, 309 insertions(+), 63 deletions(-) create mode 100644 frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx create mode 100644 frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx diff --git a/app/models/celery_script_settings_bag.rb b/app/models/celery_script_settings_bag.rb index 72c554d576..85ed5f828e 100644 --- a/app/models/celery_script_settings_bag.rb +++ b/app/models/celery_script_settings_bag.rb @@ -206,6 +206,7 @@ def self.v(symbol) url: { defn: [v(:string)] }, value: { defn: [v(:string), v(:integer), v(:boolean)] }, variance: { defn: [v(:integer)] }, + order: { defn: [v(:string)] }, version: { defn: [v(:integer)] }, x: { defn: [v(:integer), v(:float)] }, y: { defn: [v(:integer), v(:float)] }, @@ -557,6 +558,10 @@ def self.v(symbol) args: [], tags: [:data], }, + axis_order: { + args: [:order], + tags: [:data], + }, random: { args: [:variance], tags: [:data], @@ -566,6 +571,7 @@ def self.v(symbol) :axis_overwrite, :axis_addition, :speed_overwrite, + :axis_order, :safe_z, ], tags: [:function, :firmware_user], 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/frontend/__test_support__/fake_state/resources.ts b/frontend/__test_support__/fake_state/resources.ts index 5d23a96b92..286f519416 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, }); } 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/constants.ts b/frontend/constants.ts index a23d8642e4..2fb31a1157 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -467,9 +467,11 @@ export namespace ToolTips { trim(`The Move step instructs FarmBot to move to the specified coordinate or distance from its current location.`); - export const SAFE_Z = - trim(`If enabled, FarmBot will: (1) Move Z to the Safe Z height, - (2) Move X and Y to the new location, and (3) Move Z to the new location`); + export const AXIS_ORDER = + trim(`If "Safe Z" is chosen, FarmBot will: (1) Move Z to the Safe Z height, + (2) Move X and Y to the new location, and (3) Move Z to the new location. + If "None" is chosen, FarmBot will move all axes concurrently to the new + location.`); export const MOVE_ABSOLUTE = trim(`The Move To step instructs FarmBot to move to the specified diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 055418cd12..e0cfe84da1 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -588,6 +588,81 @@ describe("calculateMove()", () => { }); }); + 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: { order: "xyz" } }, + ], + }; + 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: { order: "z,xy" } }, + ], + }; + 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,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: { order: "z,y,x" } }, + ], + }; + 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[] = [ { diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index b7b9a3df8c..29bfbfad4b 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -432,6 +432,8 @@ export const calculateMove = ( return; case "safe_z": return; + case "axis_order": + return; default: warnings.push(`item kind: ${(item as MoveBodyItem).kind}`); return; @@ -448,5 +450,30 @@ export const calculateMove = ( warnings, }; } + const axisOrderItems = moveBodyItems.filter(item => item.kind === "axis_order"); + if (axisOrderItems.length > 0) { + const { order } = axisOrderItems[0].args; + switch (order) { + case "z,y,x": + return { + moves: [ + { x: current.x, y: current.y, z: pos.z }, + { x: current.x, y: pos.y, z: pos.z }, + pos, + ], + warnings, + }; + case "z,xy": + return { + moves: [ + { x: current.x, y: current.y, z: pos.z }, + pos, + ], + warnings, + }; + default: + return { moves: [pos], warnings }; + } + } return { moves: [pos], warnings }; }; diff --git a/frontend/farm_designer/__tests__/move_to_test.tsx b/frontend/farm_designer/__tests__/move_to_test.tsx index 304ac21879..3b2888c24a 100644 --- a/frontend/farm_designer/__tests__/move_to_test.tsx +++ b/frontend/farm_designer/__tests__/move_to_test.tsx @@ -37,7 +37,7 @@ describe("", () => { 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 +58,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: "None" }); + 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 +73,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 +85,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 +95,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/move_to.tsx b/frontend/farm_designer/move_to.tsx index f43f686b9e..851443842d 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"; @@ -33,11 +36,17 @@ export interface MoveToFormProps { interface MoveToFormState { z: number | undefined; safeZ: boolean; + axisOrder: string | undefined; 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, + axisOrder: undefined, + speed: 100, + }; get vector(): { x: number, y: number, z: number } { const { chosenLocation } = this.props; @@ -93,8 +102,10 @@ export class MoveToForm extends React.Component this.setState({ speed })} />
- this.setState({ safeZ: !this.state.safeZ })} /> + this.setState(getNewAxisOrderState(ddi))} />
; } } diff --git a/frontend/resources/__tests__/selectors_test.ts b/frontend/resources/__tests__/selectors_test.ts index c5150ac936..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", 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..7aef8748a2 --- /dev/null +++ b/frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { axisOrder, AxisOrderInputRow, getAxisOrderState } from "../axis_order"; +import { AxisOrderInputRowProps } from "../interfaces"; +import { Move } from "farmbot"; + +describe("", () => { + const fakeProps = (): AxisOrderInputRowProps => ({ + order: undefined, + safeZ: false, + onChange: jest.fn(), + }); + + it("renders order", () => { + const p = fakeProps(); + p.order = "z,y,x"; + render(); + expect(screen.getByText("Z then Y then X")).toBeInTheDocument(); + }); + + it("changes item", () => { + const p = fakeProps(); + render(); + const dropdown = screen.getByRole("button"); + fireEvent.click(dropdown); + const item = screen.getByRole("menuitem", { name: "Z then Y then X" }); + fireEvent.click(item); + expect(p.onChange).toHaveBeenCalledWith({ + label: "Z then Y then X", + value: "z,y,x", + }); + }); +}); + +describe("axisOrder()", () => { + it("returns node list", () => { + expect(axisOrder(undefined)).toEqual([]); + expect(axisOrder("xyz")).toEqual([ + { kind: "axis_order", args: { order: "xyz" } }, + ]); + }); +}); + +describe("getAxisOrderState()", () => { + it("returns state: axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [{ kind: "axis_order", args: { order: "z,y,x" } }], + }; + expect(getAxisOrderState(move)).toEqual("z,y,x"); + }); + + it("returns state: no axis order", () => { + const move: Move = { + kind: "move", + args: {}, + body: [], + }; + expect(getAxisOrderState(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..1606b8e739 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 @@ -114,7 +114,7 @@ describe("", () => { }); it("shows options", () => { - const MORE = ["offset", "variance", "safe"]; + const MORE = ["offset", "variance", "axis order"]; const wrapper = mount(); MORE.map(string => expect(wrapper.text().toLowerCase()).not.toContain(string)); @@ -132,9 +132,20 @@ describe("", () => { it("enables safe z", () => { const wrapper = shallow(); + expect(wrapper.state().axisOrder).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().axisOrder).toEqual(undefined); + }); + + it("enables axis order", () => { + const wrapper = shallow(); + expect(wrapper.state().axisOrder).toEqual(undefined); + expect(wrapper.state().safeZ).toEqual(false); + wrapper.instance().setAxisOrder({ label: "", value: "xyz" }); + expect(wrapper.state().safeZ).toEqual(false); + expect(wrapper.state().axisOrder).toEqual("xyz"); }); 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..83f7437aa0 --- /dev/null +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -0,0 +1,56 @@ +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 { AxisOrderInputRowProps } from "./interfaces"; + +export const axisOrder = ( + order: string | undefined, +): AxisOrder[] => + !order ? [] : [{ kind: "axis_order", args: { order } }]; + +export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => + +
+ + +
+ +
; + +const getSelectedItem = (safeZ: boolean, order: string | undefined) => { + if (safeZ) { return DDI_LOOKUP()["safe_z"]; } + if (order) { return DDI_LOOKUP()[order]; } + return undefined; +}; + +const DDIS = (): DropDownItem[] => [ + DDI_LOOKUP().safe_z, + DDI_LOOKUP()["z,y,x"], + DDI_LOOKUP()["z,xy"], +]; + +const DDI_LOOKUP = (): Record => ({ + safe_z: { label: t("Safe Z"), value: "safe_z" }, + ["z,y,x"]: { label: t("Z then Y then X"), value: "z,y,x" }, + ["z,xy"]: { label: t("Z then X and Y"), value: "z,xy" }, + ["xyz"]: { label: t("X and Y and Z"), value: "xyz" }, +}); + +export const getAxisOrderState = (step: Move) => { + const axisOrder = step.body?.find(x => x.kind == "axis_order"); + if (axisOrder?.kind == "axis_order") { + return axisOrder.args.order; + } +}; + +export const getNewAxisOrderState = (ddi: DropDownItem) => { + const safeZ = ddi.value == "safe_z"; + const axisOrder = "" + ddi.value; + return { axisOrder: safeZ ? undefined : axisOrder, 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..d87dd3eac4 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,7 +27,10 @@ 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, getAxisOrderState, getNewAxisOrderState, +} from "./axis_order"; import { StepParams } from "../../interfaces"; /** @@ -98,6 +101,7 @@ export class ComputedMove z: getSpeedState(this.step, "z"), }, safeZ: getSafeZState(this.step), + axisOrder: getAxisOrderState(this.step), }; get step() { return this.props.currentStep; } @@ -178,6 +182,7 @@ export class ComputedMove ...speedOverwrite("y", this.speedNodes.y), ...speedOverwrite("z", this.speedNodes.z), ...(this.state.safeZ ? [SAFE_Z] : []), + ...axisOrder(this.state.axisOrder), ]; }; @@ -241,7 +246,9 @@ export class ComputedMove } }, this.update); - toggleSafeZ = () => this.setState({ safeZ: !this.state.safeZ }, this.update); + setAxisOrder = (ddi: DropDownItem) => { + this.setState(getNewAxisOrderState(ddi), this.update); + }; toggleMore = () => this.setState({ more: !this.state.more }); LocationInputRow = () => @@ -304,10 +311,12 @@ export class ComputedMove setAxisState={this.setAxisState} /> : undefined; - SafeZCheckbox = () => - (this.state.safeZ || this.state.more) - ? + AxisOrderInputRow = () => + (this.state.axisOrder || this.state.safeZ || this.state.more) + ? : undefined; render() { @@ -327,7 +336,7 @@ export class ComputedMove - + ; } } diff --git a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts index 3bb0c9cc3b..f643619d01 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts +++ b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts @@ -3,6 +3,7 @@ import { } from "farmbot"; import { ResourceIndex, UUID } from "../../../resources/interfaces"; import { BotPosition } from "../../../devices/interfaces"; +import { DropDownItem } from "../../../ui"; export type LocationNode = Identifier | Point | Tool; @@ -16,6 +17,7 @@ export interface ComputedMoveState { variance: Record; speed: Record; safeZ: boolean; + axisOrder: string | undefined; viewRaw?: boolean; } @@ -68,11 +70,6 @@ export interface LocationSelectionProps { sequenceUuid: UUID; } -export interface SafeZCheckboxProps { - checked: boolean; - onChange(): void; -} - interface InputRowBase { disabledAxes: Record; onCommit: CommitMoveField; @@ -96,6 +93,12 @@ export interface SpeedInputRowProps extends InputRowBase { setAxisState: SetAxisState; } +export interface AxisOrderInputRowProps { + onChange(ddi: DropDownItem): void; + order: string | undefined; + safeZ: boolean; +} + 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..1e741e219b 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,7 @@ export const fakeNumericMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: 10, y: 10, z: 10 }, safeZ: true, + axisOrder: undefined, }); export const fakeNumericMoveStepCeleryScript: Move = { @@ -144,6 +145,7 @@ export const fakeLuaMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: "10", y: "10", z: "10" }, safeZ: true, + axisOrder: undefined, }); export const fakeLuaMoveStepCeleryScript: Move = { diff --git a/package.json b/package.json index c35c5ffecb..3e46af53f2 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", - "farmbot": "15.8.11", + "farmbot": "15.9.0", "fengari": "0.1.4", "fengari-web": "0.1.4", "i18next": "25.3.2", From ebad9c69dcd86d5fadf1f9492370378892aa6ada Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 24 Jul 2025 14:19:24 -0700 Subject: [PATCH 20/54] add seeder_tip_z_offset --- app/mutations/tools/create.rb | 1 + app/mutations/tools/update.rb | 1 + app/serializers/tool_serializer.rb | 2 +- .../20250722234106_add_seeder_tip_z_offset.rb | 9 ++++ db/structure.sql | 6 ++- frontend/tools/__tests__/add_tool_test.tsx | 21 +++++++- frontend/tools/__tests__/edit_tool_test.tsx | 51 ++++++++++++++++--- frontend/tools/add_tool.tsx | 15 +++++- frontend/tools/edit_tool.tsx | 21 ++++++++ frontend/tools/interfaces.ts | 2 + 10 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20250722234106_add_seeder_tip_z_offset.rb 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/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/structure.sql b/db/structure.sql index 948a6db753..037175f7b7 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1003,7 +1003,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 +3984,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241203211516'), ('20250221191831'), ('20250502201109'), -('20250514203443'); +('20250514203443'), +('20250722234106'); 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/add_tool.tsx b/frontend/tools/add_tool.tsx index ac09dc996e..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; @@ -203,6 +211,9 @@ export class RawAddTool extends React.Component { {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 bf843dacf1..e5b8ba8bfe 100644 --- a/frontend/tools/edit_tool.tsx +++ b/frontend/tools/edit_tool.tsx @@ -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()); @@ -147,6 +165,9 @@ export class RawEditTool extends React.Component { {reduceToolName(toolName) == ToolName.wateringNozzle && } + {reduceToolName(toolName) == ToolName.seeder && + }

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

diff --git a/frontend/tools/interfaces.ts b/frontend/tools/interfaces.ts index c6db9bcaaa..a64dfa3c79 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 { From f8891395bc8f169aaf8edb5b4db4839a4aa19be8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 24 Jul 2025 18:04:05 -0700 Subject: [PATCH 21/54] fix lua runner move helper --- .../demo/lua_runner/__tests__/run_test.ts | 8 ++- frontend/demo/lua_runner/actions.ts | 52 ++++++++----------- frontend/demo/lua_runner/lua.ts | 7 ++- frontend/demo/lua_runner/run.ts | 7 --- frontend/demo/lua_runner/util.ts | 8 +-- 5 files changed, 39 insertions(+), 43 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/run_test.ts b/frontend/demo/lua_runner/__tests__/run_test.ts index 9008e4d62c..b55ac633f0 100644 --- a/frontend/demo/lua_runner/__tests__/run_test.ts +++ b/frontend/demo/lua_runner/__tests__/run_test.ts @@ -12,7 +12,13 @@ describe("runLua()", () => { { type: "move_absolute", args: [1, 2, 3] }, { type: "wait_ms", args: [1000] }, { type: "go_to_home", args: ["all"] }, - { type: "move", args: [undefined, 1, undefined] }, + { + type: "_move", + args: [ + "[{\"kind\":\"axis_overwrite\",\"args\":{\"axis\":\"y\",\"" + + "axis_operand\":{\"kind\":\"numeric\",\"args\":{\"number\":1}}}}]", + ], + }, ]); }); }); diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 29bfbfad4b..6d40988576 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,5 +1,6 @@ import { Identifier, MoveBodyItem, ParameterApplication, PercentageProgress, + Xyz, } from "farmbot"; import { info } from "../../toast/toast"; import { store } from "../../redux/store"; @@ -135,15 +136,6 @@ export const expandActions = ( setCurrent(actualMoveTarget); }); break; - case "move": - const moveTarget = clampTarget({ - x: (action.args[0] as number | undefined) ?? current.x, - y: (action.args[1] as number | undefined) ?? current.y, - z: (action.args[2] as number | undefined) ?? current.z, - }); - movementChunks(current, moveTarget, mmPerTimeStep).map(addPosition); - setCurrent(moveTarget); - break; case "find_home": case "go_to_home": const axisInput = action.args[0] as string; @@ -453,27 +445,27 @@ export const calculateMove = ( const axisOrderItems = moveBodyItems.filter(item => item.kind === "axis_order"); if (axisOrderItems.length > 0) { const { order } = axisOrderItems[0].args; - switch (order) { - case "z,y,x": - return { - moves: [ - { x: current.x, y: current.y, z: pos.z }, - { x: current.x, y: pos.y, z: pos.z }, - pos, - ], - warnings, - }; - case "z,xy": - return { - moves: [ - { x: current.x, y: current.y, z: pos.z }, - pos, - ], - warnings, - }; - default: - return { moves: [pos], warnings }; - } + const moves = generateMoves(order, current, pos); + return { moves, warnings }; } return { moves: [pos], warnings }; }; + +const generateMoves = (order: string, current: XyzNumber, target: XyzNumber) => { + const axes: Xyz[] = ["x", "y", "z"]; + const groups = order.split(","); + const moves: XyzNumber[] = []; + let lastState = { ...current }; + groups.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/lua.ts b/frontend/demo/lua_runner/lua.ts index c5f33f1045..0789586d14 100644 --- a/frontend/demo/lua_runner/lua.ts +++ b/frontend/demo/lua_runner/lua.ts @@ -599,7 +599,11 @@ function speed_overwrite(axis, num) } end -function re_move(input) +function axis_order(order) + return { kind = "axis_order", args = { order = order } } +end + +function move(input) cs_eval({ kind = "rpc_request", args = {label = "move_cmd_lua", priority = 500}, @@ -614,6 +618,7 @@ function re_move(input) 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.axis_order and axis_order(input.axis_order), input.safe_z and {kind = "safe_z", args = {}} } } diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index af5d414ae2..6ad6b30904 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -349,13 +349,6 @@ export const runLua = }); lua.lua_setfield(L, envIndex, to_luastring("soil_height")); - lua.lua_pushjsfunction(L, () => { - const args = luaToJs(L, 1) as Partial; - actions.push({ type: "move", args: [args.x, args.y, args.z] }); - return 0; - }); - lua.lua_setfield(L, envIndex, to_luastring("move")); - lua.lua_pushjsfunction(L, () => { const arg = luaToJs(L, 1) as string; actions.push({ type: "_move", args: [arg] }); diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts index 17325c6ea6..42f2f4094f 100644 --- a/frontend/demo/lua_runner/util.ts +++ b/frontend/demo/lua_runner/util.ts @@ -65,10 +65,10 @@ const luaTableToJs = (L: unknown, idx: number): unknown => { keyVals.push([key, val]); lua.lua_pop(L, 1); } - const isSequentialArray = - keyVals.every(([k], i) => typeof k === "number" && k === i + 1); - if (isSequentialArray) { - return keyVals.map(([, v]) => v); + 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) { From 3a8e1d6c049c473b3dab80ffa6edd60c5484f39e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 29 Jul 2025 16:29:37 -0700 Subject: [PATCH 22/54] change move axis_order to grouping and route --- app/models/celery_script_settings_bag.rb | 13 +- .../demo/lua_runner/__tests__/actions_test.ts | 183 +++++++++++++++++- frontend/demo/lua_runner/actions.ts | 46 ++++- frontend/demo/lua_runner/lua.ts | 8 +- frontend/farm_designer/move_to.tsx | 13 +- .../__tests__/axis_order_test.tsx | 65 +++++-- .../__tests__/component_test.tsx | 27 ++- .../tile_computed_move/axis_order.tsx | 120 ++++++++++-- .../tile_computed_move/component.tsx | 17 +- .../tile_computed_move/interfaces.ts | 11 +- .../tile_computed_move/test_fixtures.ts | 6 +- package.json | 2 +- 12 files changed, 443 insertions(+), 68 deletions(-) diff --git a/app/models/celery_script_settings_bag.rb b/app/models/celery_script_settings_bag.rb index 85ed5f828e..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,7 +214,8 @@ def self.v(symbol) url: { defn: [v(:string)] }, value: { defn: [v(:string), v(:integer), v(:boolean)] }, variance: { defn: [v(:integer)] }, - order: { defn: [v(:string)] }, + 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)] }, @@ -559,7 +568,7 @@ def self.v(symbol) tags: [:data], }, axis_order: { - args: [:order], + args: [:grouping, :route], tags: [:data], }, random: { diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index e0cfe84da1..ba4b10fe46 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -600,7 +600,7 @@ describe("calculateMove()", () => { axis_operand: { kind: "numeric", args: { number: 100 } }, }, }, - { kind: "axis_order", args: { order: "xyz" } }, + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, ], }; expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) @@ -624,7 +624,7 @@ describe("calculateMove()", () => { axis_operand: { kind: "numeric", args: { number: 100 } }, }, }, - { kind: "axis_order", args: { order: "z,xy" } }, + { kind: "axis_order", args: { grouping: "z,xy", route: "in_order" } }, ], }; expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) @@ -637,6 +637,183 @@ describe("calculateMove()", () => { }); }); + 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", @@ -649,7 +826,7 @@ describe("calculateMove()", () => { axis_operand: { kind: "numeric", args: { number: 100 } }, }, }, - { kind: "axis_order", args: { order: "z,y,x" } }, + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, ], }; expect(calculateMove(command.body, { x: 50, y: 50, z: 50 }, [])) diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 6d40988576..4c0cbc4126 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,4 +1,6 @@ import { + ALLOWED_GROUPING, + ALLOWED_ROUTE, Identifier, MoveBodyItem, ParameterApplication, PercentageProgress, Xyz, } from "farmbot"; @@ -444,19 +446,53 @@ export const calculateMove = ( } const axisOrderItems = moveBodyItems.filter(item => item.kind === "axis_order"); if (axisOrderItems.length > 0) { - const { order } = axisOrderItems[0].args; - const moves = generateMoves(order, current, pos); + const { grouping, route } = axisOrderItems[0].args; + const moves = generateMoves(grouping, route, current, pos); return { moves, warnings }; } return { moves: [pos], warnings }; }; -const generateMoves = (order: string, current: XyzNumber, target: XyzNumber) => { +const generateMoves = ( + grouping: ALLOWED_GROUPING, + route: ALLOWED_ROUTE, + current: XyzNumber, + target: XyzNumber, +) => { const axes: Xyz[] = ["x", "y", "z"]; - const groups = order.split(","); + 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 }; - groups.map(group => { + reorder(groupsInput).map(group => { const normalized = group.split("").sort().join(""); const movement = { ...lastState }; axes.map(axis => { diff --git a/frontend/demo/lua_runner/lua.ts b/frontend/demo/lua_runner/lua.ts index 0789586d14..547ebbfcec 100644 --- a/frontend/demo/lua_runner/lua.ts +++ b/frontend/demo/lua_runner/lua.ts @@ -599,8 +599,10 @@ function speed_overwrite(axis, num) } end -function axis_order(order) - return { kind = "axis_order", args = { order = order } } +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) @@ -618,7 +620,7 @@ function move(input) 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.axis_order and axis_order(input.axis_order), + (input.grouping or input.route) and axis_order(input), input.safe_z and {kind = "safe_z", args = {}} } } diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx index 851443842d..3ea2a50692 100644 --- a/frontend/farm_designer/move_to.tsx +++ b/frontend/farm_designer/move_to.tsx @@ -24,6 +24,7 @@ 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; @@ -36,7 +37,8 @@ export interface MoveToFormProps { interface MoveToFormState { z: number | undefined; safeZ: boolean; - axisOrder: string | undefined; + axisGrouping: AxisGrouping; + axisRoute: AxisRoute; speed: number; } @@ -44,7 +46,8 @@ export class MoveToForm extends React.Component this.setState(getNewAxisOrderState(ddi))} /> + grouping={this.state.axisGrouping} + route={this.state.axisRoute} + onChange={ddi => + this.setState({ ...this.state, ...getNewAxisOrderState(ddi) })} />
; } } 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 index 7aef8748a2..e7b09c37c0 100644 --- 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 @@ -1,21 +1,33 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; -import { axisOrder, AxisOrderInputRow, getAxisOrderState } from "../axis_order"; -import { AxisOrderInputRowProps } from "../interfaces"; +import { + axisOrder, AxisOrderInputRow, getAxisGroupingState, getAxisRouteState, +} from "../axis_order"; +import { AxisGrouping, AxisOrderInputRowProps, AxisRoute } from "../interfaces"; import { Move } from "farmbot"; describe("", () => { const fakeProps = (): AxisOrderInputRowProps => ({ - order: undefined, + grouping: undefined, + route: undefined, safeZ: false, onChange: jest.fn(), }); - it("renders order", () => { + 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, "None"], + [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.order = "z,y,x"; + p.grouping = grouping; + p.route = route; + p.safeZ = safeZ; render(); - expect(screen.getByText("Z then Y then X")).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); it("changes item", () => { @@ -23,32 +35,32 @@ describe("", () => { render(); const dropdown = screen.getByRole("button"); fireEvent.click(dropdown); - const item = screen.getByRole("menuitem", { name: "Z then Y then X" }); + const item = screen.getByRole("menuitem", { name: "X and Y together" }); fireEvent.click(item); expect(p.onChange).toHaveBeenCalledWith({ - label: "Z then Y then X", - value: "z,y,x", + label: "X and Y together", + value: "xy,z;high", }); }); }); describe("axisOrder()", () => { it("returns node list", () => { - expect(axisOrder(undefined)).toEqual([]); - expect(axisOrder("xyz")).toEqual([ - { kind: "axis_order", args: { order: "xyz" } }, + expect(axisOrder(undefined, undefined)).toEqual([]); + expect(axisOrder("xyz", "in_order")).toEqual([ + { kind: "axis_order", args: { grouping: "xyz", route: "in_order" } }, ]); }); }); -describe("getAxisOrderState()", () => { +describe("getAxisGroupingState()", () => { it("returns state: axis order", () => { const move: Move = { kind: "move", args: {}, - body: [{ kind: "axis_order", args: { order: "z,y,x" } }], + body: [{ kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }], }; - expect(getAxisOrderState(move)).toEqual("z,y,x"); + expect(getAxisGroupingState(move)).toEqual("z,y,x"); }); it("returns state: no axis order", () => { @@ -57,6 +69,27 @@ describe("getAxisOrderState()", () => { args: {}, body: [], }; - expect(getAxisOrderState(move)).toEqual(undefined); + 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 1606b8e739..e21a74b0bd 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 @@ -123,6 +123,19 @@ describe("", () => { expect(wrapper.text().toLowerCase()).toContain(string)); }); + it("shows options: axis order", () => { + const MORE = ["axis 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,20 +145,24 @@ describe("", () => { it("enables safe z", () => { const wrapper = shallow(); - expect(wrapper.state().axisOrder).toEqual(undefined); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); expect(wrapper.state().safeZ).toEqual(false); wrapper.instance().setAxisOrder({ label: "", value: "safe_z" }); expect(wrapper.state().safeZ).toEqual(true); - expect(wrapper.state().axisOrder).toEqual(undefined); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); }); it("enables axis order", () => { const wrapper = shallow(); - expect(wrapper.state().axisOrder).toEqual(undefined); + expect(wrapper.state().axisGrouping).toEqual(undefined); + expect(wrapper.state().axisRoute).toEqual(undefined); expect(wrapper.state().safeZ).toEqual(false); - wrapper.instance().setAxisOrder({ label: "", value: "xyz" }); + wrapper.instance().setAxisOrder({ label: "", value: "xyz;high" }); expect(wrapper.state().safeZ).toEqual(false); - expect(wrapper.state().axisOrder).toEqual("xyz"); + expect(wrapper.state().axisGrouping).toEqual("xyz"); + expect(wrapper.state().axisRoute).toEqual("high"); }); it("commits number value", () => { diff --git a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx index 83f7437aa0..7475cb08ae 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -3,12 +3,15 @@ import { Row, Help, FBSelect, DropDownItem } from "../../../ui"; import { t } from "../../../i18next_wrapper"; import { ToolTips } from "../../../constants"; import { Move, AxisOrder } from "farmbot"; -import { AxisOrderInputRowProps } from "./interfaces"; +import { + AxisGrouping, AxisOrderInputRowProps, AxisRoute, +} from "./interfaces"; export const axisOrder = ( - order: string | undefined, + grouping: AxisGrouping, + route: AxisRoute, ): AxisOrder[] => - !order ? [] : [{ kind: "axis_order", args: { order } }]; + !(grouping && route) ? [] : [{ kind: "axis_order", args: { grouping, route } }]; export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => @@ -17,40 +20,119 @@ export const AxisOrderInputRow = (props: AxisOrderInputRowProps) =>
; -const getSelectedItem = (safeZ: boolean, order: string | undefined) => { - if (safeZ) { return DDI_LOOKUP()["safe_z"]; } - if (order) { return DDI_LOOKUP()[order]; } +const getSelectedItem = ( + 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()["z,y,x"], - DDI_LOOKUP()["z,xy"], + DDI_LOOKUP()[ddiValue("x,y,z", "high")], + DDI_LOOKUP()[ddiValue("xy,z", "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 DDI_LOOKUP = (): Record => ({ - safe_z: { label: t("Safe Z"), value: "safe_z" }, - ["z,y,x"]: { label: t("Z then Y then X"), value: "z,y,x" }, - ["z,xy"]: { label: t("Z then X and Y"), value: "z,xy" }, - ["xyz"]: { label: t("X and Y and Z"), value: "xyz" }, -}); +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); +}; + +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 getAxisOrderState = (step: Move) => { +export const getAxisRouteState = (step: Move) => { const axisOrder = step.body?.find(x => x.kind == "axis_order"); if (axisOrder?.kind == "axis_order") { - return axisOrder.args.order; + return axisOrder.args.route; } }; export const getNewAxisOrderState = (ddi: DropDownItem) => { const safeZ = ddi.value == "safe_z"; - const axisOrder = "" + ddi.value; - return { axisOrder: safeZ ? undefined : axisOrder, safeZ }; + 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 d87dd3eac4..ca73cd8068 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/component.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/component.tsx @@ -29,7 +29,8 @@ import { } from "./speed"; import { getSafeZState, SAFE_Z } from "./safe_z"; import { - axisOrder, AxisOrderInputRow, getAxisOrderState, getNewAxisOrderState, + axisOrder, AxisOrderInputRow, getAxisGroupingState, getAxisRouteState, + getNewAxisOrderState, } from "./axis_order"; import { StepParams } from "../../interfaces"; @@ -101,7 +102,8 @@ export class ComputedMove z: getSpeedState(this.step, "z"), }, safeZ: getSafeZState(this.step), - axisOrder: getAxisOrderState(this.step), + axisGrouping: getAxisGroupingState(this.step), + axisRoute: getAxisRouteState(this.step), }; get step() { return this.props.currentStep; } @@ -182,7 +184,7 @@ export class ComputedMove ...speedOverwrite("y", this.speedNodes.y), ...speedOverwrite("z", this.speedNodes.z), ...(this.state.safeZ ? [SAFE_Z] : []), - ...axisOrder(this.state.axisOrder), + ...axisOrder(this.state.axisGrouping, this.state.axisRoute), ]; }; @@ -247,7 +249,7 @@ export class ComputedMove }, this.update); setAxisOrder = (ddi: DropDownItem) => { - this.setState(getNewAxisOrderState(ddi), this.update); + this.setState({ ...this.state, ...getNewAxisOrderState(ddi) }, this.update); }; toggleMore = () => this.setState({ more: !this.state.more }); @@ -312,9 +314,12 @@ export class ComputedMove : undefined; AxisOrderInputRow = () => - (this.state.axisOrder || this.state.safeZ || this.state.more) + ((this.state.axisGrouping && this.state.axisRoute) + || this.state.safeZ + || this.state.more) ? : undefined; diff --git a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts index f643619d01..54333db03d 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts +++ b/frontend/sequences/step_tiles/tile_computed_move/interfaces.ts @@ -1,5 +1,7 @@ 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"; @@ -7,6 +9,9 @@ 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; @@ -17,7 +22,8 @@ export interface ComputedMoveState { variance: Record; speed: Record; safeZ: boolean; - axisOrder: string | undefined; + axisGrouping: AxisGrouping; + axisRoute: AxisRoute; viewRaw?: boolean; } @@ -95,7 +101,8 @@ export interface SpeedInputRowProps extends InputRowBase { export interface AxisOrderInputRowProps { onChange(ddi: DropDownItem): void; - order: string | undefined; + grouping: AxisGrouping; + route: AxisRoute; safeZ: boolean; } 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 1e741e219b..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,7 +15,8 @@ export const fakeNumericMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: 10, y: 10, z: 10 }, safeZ: true, - axisOrder: undefined, + axisGrouping: undefined, + axisRoute: undefined, }); export const fakeNumericMoveStepCeleryScript: Move = { @@ -145,7 +146,8 @@ export const fakeLuaMoveStepState: ComputedMoveState = ({ variance: { x: 7, y: 8, z: 9 }, speed: { x: "10", y: "10", z: "10" }, safeZ: true, - axisOrder: undefined, + axisGrouping: undefined, + axisRoute: undefined, }); export const fakeLuaMoveStepCeleryScript: Move = { diff --git a/package.json b/package.json index 3e46af53f2..71daaaa4f1 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", - "farmbot": "15.9.0", + "farmbot": "15.9.1", "fengari": "0.1.4", "fengari-web": "0.1.4", "i18next": "25.3.2", From ada920dd8f77649f165719fda5039c18f264f132 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 30 Jul 2025 22:08:44 -0700 Subject: [PATCH 23/54] fix watering animation --- app/mutations/devices/seeders/demo_account_seeder.rb | 1 - frontend/constants.ts | 4 +--- frontend/farm_designer/__tests__/move_to_test.tsx | 2 +- frontend/points/point_inventory_item.tsx | 4 ++-- .../tile_computed_move/__tests__/axis_order_test.tsx | 2 +- .../tile_computed_move/__tests__/component_test.tsx | 4 ++-- .../step_tiles/tile_computed_move/axis_order.tsx | 3 ++- frontend/three_d_garden/bot/__tests__/bot_test.tsx | 5 ++++- .../components/__tests__/watering_animations_test.tsx | 5 ++++- .../bot/components/watering_animations.tsx | 10 ++++++++-- 10 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index c2921ed2fd..0cb880b7eb 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -140,7 +140,6 @@ def before_product_line_seeder .update!( discard_unsaved: true, three_d_garden: true, - show_points: false ) device .fbos_config diff --git a/frontend/constants.ts b/frontend/constants.ts index 2fb31a1157..abc04bd47d 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -469,9 +469,7 @@ export namespace ToolTips { export const AXIS_ORDER = trim(`If "Safe Z" is chosen, FarmBot will: (1) Move Z to the Safe Z height, - (2) Move X and Y to the new location, and (3) Move Z to the new location. - If "None" is chosen, FarmBot will move all axes concurrently to the new - location.`); + (2) Move X and Y to the new location, and (3) Move Z to the new location.`); export const MOVE_ABSOLUTE = trim(`The Move To step instructs FarmBot to move to the specified diff --git a/frontend/farm_designer/__tests__/move_to_test.tsx b/frontend/farm_designer/__tests__/move_to_test.tsx index 3b2888c24a..bd63b5df54 100644 --- a/frontend/farm_designer/__tests__/move_to_test.tsx +++ b/frontend/farm_designer/__tests__/move_to_test.tsx @@ -60,7 +60,7 @@ describe("", () => { it("changes safe z value", () => { render(); expect(screen.queryByText("Safe Z")).not.toBeInTheDocument(); - const dropdown = screen.getByRole("button", { name: "None" }); + const dropdown = screen.getByRole("button", { name: "All at once" }); fireEvent.click(dropdown); expect(screen.getAllByText("Safe Z").length).toEqual(1); const item = screen.getByRole("menuitem", { name: "Safe Z" }); 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/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx b/frontend/sequences/step_tiles/tile_computed_move/__tests__/axis_order_test.tsx index e7b09c37c0..0ffdc0ffb8 100644 --- 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 @@ -18,7 +18,7 @@ describe("", () => { [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, "None"], + [false, undefined, undefined, "All at once"], [false, "x", "low", "x;low"], [true, "x", "low", "Safe Z"], ])("renders order: safe_z=%s %s %s", (safeZ, grouping, route, label) => { 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 e21a74b0bd..51756046bc 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 @@ -114,7 +114,7 @@ describe("", () => { }); it("shows options", () => { - const MORE = ["offset", "variance", "axis order"]; + const MORE = ["offset", "variance", "order"]; const wrapper = mount(); MORE.map(string => expect(wrapper.text().toLowerCase()).not.toContain(string)); @@ -124,7 +124,7 @@ describe("", () => { }); it("shows options: axis order", () => { - const MORE = ["axis order"]; + const MORE = ["order"]; const p = fakeProps(); p.currentStep = { kind: "move", args: {}, body: [{ diff --git a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx index 7475cb08ae..789fb861a4 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -16,13 +16,14 @@ export const axisOrder = ( export const AxisOrderInputRow = (props: AxisOrderInputRowProps) =>
- +
; diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index e61a00a8d0..bf77716842 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -60,7 +60,10 @@ describe("", () => { it("renders watering animation", () => { const p = fakeProps(); p.config.waterFlow = true; - const { container } = render(); + jest.useFakeTimers(); + const { container, rerender } = render(); + jest.runAllTimers(); + rerender(); expect(container).toContainHTML("watering-animations"); }); 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 c9d67a2ddf..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 @@ -14,8 +14,11 @@ describe("", () => { }); 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/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx index 70e3e26fae..ff7cc213f1 100644 --- a/frontend/three_d_garden/bot/components/watering_animations.tsx +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -19,9 +19,15 @@ export const WateringAnimations = (props: WateringAnimationsProps) => { 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 Date: Wed, 30 Jul 2025 22:09:21 -0700 Subject: [PATCH 24/54] add dev settings and fix inactive color --- frontend/__test_support__/fake_state/bot.ts | 1 + frontend/constants.ts | 1 + frontend/css/global/buttons.scss | 5 +-- .../demo/lua_runner/__tests__/actions_test.ts | 10 +++++ .../demo/lua_runner/__tests__/index_test.ts | 14 +++---- frontend/demo/lua_runner/actions.ts | 10 +++-- frontend/devices/__tests__/reducer_test.ts | 7 ++++ frontend/devices/interfaces.ts | 1 + frontend/devices/reducer.ts | 6 +++ .../farm_designer/__tests__/move_to_test.tsx | 4 ++ .../__tests__/axis_order_test.tsx | 18 +++++++++ .../tile_computed_move/axis_order.tsx | 6 ++- .../dev/__tests__/dev_settings_test.tsx | 38 +++++++++++++++++++ frontend/settings/dev/dev_settings.tsx | 28 ++++++++++++++ frontend/settings/dev/dev_support.ts | 9 +++++ 15 files changed, 143 insertions(+), 15 deletions(-) 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/constants.ts b/frontend/constants.ts index abc04bd47d..333694c7df 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2441,6 +2441,7 @@ export enum Actions { DEMO_SET_JOB_PROGRESS = "DEMO_SET_JOB_PROGRESS", DEMO_SET_ESTOP = "DEMO_SET_ESTOP", DEMO_SET_MOUNTED_TOOL_ID = "DEMO_SET_MOUNTED_TOOL_ID", + DEMO_SET_QUEUE_LENGTH = "DEMO_SET_QUEUE_LENGTH", // Draggable PUT_DATA_XFER = "PUT_DATA_XFER", diff --git a/frontend/css/global/buttons.scss b/frontend/css/global/buttons.scss index 8134154e36..940f19a0db 100644 --- a/frontend/css/global/buttons.scss +++ b/frontend/css/global/buttons.scss @@ -301,9 +301,6 @@ } } .inactive { - color: $gray; - } - .fa-eye-slash { - color: $gray; + color: var(--secondary-bg); } } diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index ba4b10fe46..363596c606 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -114,6 +114,16 @@ describe("expandActions()", () => { ]); }); + 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: {} }])] }, diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 49ab0a4b6d..d17db928a3 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -294,7 +294,7 @@ describe("runDemoSequence()", () => { jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); - expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledTimes(1); }); it("runs move sequence step", () => { @@ -326,7 +326,7 @@ describe("runDemoSequence()", () => { type: Actions.DEMO_SET_POSITION, payload: { x: 2, y: 4, z: 6 }, }); - expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledTimes(1); }); it("applies sequence variables", () => { @@ -347,7 +347,7 @@ describe("runDemoSequence()", () => { runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); expect(info).toHaveBeenCalledWith("v", TOAST_OPTIONS().info); - expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledTimes(1); expect(error).not.toHaveBeenCalled(); }); @@ -376,7 +376,7 @@ describe("runDemoSequence()", () => { runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); expect(info).toHaveBeenCalledWith("abc", TOAST_OPTIONS().info); - expect(console.log).toHaveBeenCalledTimes(2); + expect(console.log).toHaveBeenCalledTimes(1); expect(error).not.toHaveBeenCalled(); }); @@ -616,7 +616,7 @@ describe("runDemoLuaCode()", () => { radius = 5 }}`); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(0); expect(info).not.toHaveBeenCalled(); expect(initSave).toHaveBeenCalledWith("Point", { name: "test", pointer_type: "GenericPointer", @@ -637,7 +637,7 @@ describe("runDemoLuaCode()", () => { radius = 5 }}`); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(0); expect(info).not.toHaveBeenCalled(); expect(initSave).toHaveBeenCalledWith("Point", { name: "test", pointer_type: "GenericPointer", @@ -1008,7 +1008,7 @@ describe("runDemoLuaCode()", () => { runDemoLuaCode("update_device{ mounted_tool_id = 1 }"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(0); expect(info).not.toHaveBeenCalled(); expect(edit).toHaveBeenCalledWith(device, { mounted_tool_id: 1 }); expect(save).toHaveBeenCalledWith(device.uuid); diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 4c0cbc4126..d3059372d6 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -46,7 +46,9 @@ const movementChunks = ( y: dy / length, z: dz / length, }; - const steps = Math.floor(length / mmPerTimeStep); + const steps = localStorage.getItem("DISABLE_CHUNKING") === "true" + ? 0 + : Math.floor(length / mmPerTimeStep); const chunks: XyzNumber[] = []; for (let i = 1; i <= steps; i++) { const step = { @@ -171,7 +173,6 @@ let currentTimer: ReturnType | undefined = undefined; export const eStop = () => { latestActionMs = 0; pending.length = 0; - console.log(`Queue length: ${pending.length}`); store.dispatch({ type: Actions.DEMO_SET_ESTOP, payload: true, @@ -305,7 +306,10 @@ const runNext = () => { currentTimer = undefined; const task = pending.shift(); task?.func(); - console.log(`Queue length: ${pending.length}`); + store.dispatch({ + type: Actions.DEMO_SET_QUEUE_LENGTH, + payload: pending.length, + }); runNext(); }, delay); }; diff --git a/frontend/devices/__tests__/reducer_test.ts b/frontend/devices/__tests__/reducer_test.ts index d31c32c437..d40c6ec492 100644 --- a/frontend/devices/__tests__/reducer_test.ts +++ b/frontend/devices/__tests__/reducer_test.ts @@ -241,4 +241,11 @@ describe("botReducer", () => { 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/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/reducer.ts b/frontend/devices/reducer.ts index b22f1fac81..78a010664c 100644 --- a/frontend/devices/reducer.ts +++ b/frontend/devices/reducer.ts @@ -75,6 +75,7 @@ export const initialState = (): BotState => ({ }, needVersionCheck: true, alreadyToldUserAboutMalformedMsg: false, + demoQueueLength: 0, }); export const botReducer = generateReducer(initialState()) @@ -155,6 +156,11 @@ export const botReducer = generateReducer(initialState()) .add(Actions.DEMO_SET_ESTOP, (s, { payload }) => { s.hardware.informational_settings.locked = payload; s.hardware.pins = {}; + s.demoQueueLength = 0; + return s; + }) + .add(Actions.DEMO_SET_QUEUE_LENGTH, (s, { payload }) => { + s.demoQueueLength = payload; return s; }) .add(Actions.PING_OK, (s) => { diff --git a/frontend/farm_designer/__tests__/move_to_test.tsx b/frontend/farm_designer/__tests__/move_to_test.tsx index bd63b5df54..90b2c11ffe 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"; 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 index 0ffdc0ffb8..d5172aa2b5 100644 --- 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 @@ -1,3 +1,8 @@ +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 { @@ -7,6 +12,10 @@ import { AxisGrouping, AxisOrderInputRowProps, AxisRoute } from "../interfaces"; import { Move } from "farmbot"; describe("", () => { + beforeEach(() => { + mockDev = false; + }); + const fakeProps = (): AxisOrderInputRowProps => ({ grouping: undefined, route: undefined, @@ -42,6 +51,15 @@ describe("", () => { value: "xy,z;high", }); }); + + 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(); + }); }); describe("axisOrder()", () => { diff --git a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx index 789fb861a4..da94234fc5 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -6,6 +6,7 @@ import { Move, AxisOrder } from "farmbot"; import { AxisGrouping, AxisOrderInputRowProps, AxisRoute, } from "./interfaces"; +import { DevSettings } from "../../../settings/dev/dev_support"; export const axisOrder = ( grouping: AxisGrouping, @@ -21,7 +22,7 @@ export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => @@ -114,6 +115,9 @@ const DDI_LOOKUP = (): Record => { {} 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") { 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); } From 06c716ba688e41f45cb495fe8c08798f877dcced Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 31 Jul 2025 10:49:54 -0700 Subject: [PATCH 25/54] move calculate move code --- .../demo/lua_runner/__tests__/actions_test.ts | 797 +---------------- .../__tests__/calculate_move_test.ts | 819 ++++++++++++++++++ frontend/demo/lua_runner/actions.ts | 216 +---- frontend/demo/lua_runner/calculate_move.ts | 212 +++++ 4 files changed, 1037 insertions(+), 1007 deletions(-) create mode 100644 frontend/demo/lua_runner/__tests__/calculate_move_test.ts create mode 100644 frontend/demo/lua_runner/calculate_move.ts diff --git a/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 363596c606..0b915b10be 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -4,9 +4,6 @@ import { import { fakeFbosConfig, fakeFirmwareConfig, - fakePlant, - fakeTool, - fakeToolSlot, fakeWebAppConfig, } from "../../../__test_support__/fake_state/resources"; let mockResources = buildResourceIndex([]); @@ -25,18 +22,9 @@ jest.mock("../../../redux/store", () => ({ }, })); -jest.mock("../../../three_d_garden/triangle_functions", () => ({ - getZFunc: jest.fn(() => () => 3), -})); - -import { - AxisAddition, AxisOverwrite, Move, MoveBodyItem, ParameterApplication, -} from "farmbot"; import { TOAST_OPTIONS } from "../../../toast/constants"; import { info } from "../../../toast/toast"; -import { - calculateMove, eStop, expandActions, runActions, setCurrent, -} from "../actions"; +import { eStop, expandActions, runActions, setCurrent } from "../actions"; describe("runActions()", () => { beforeEach(() => { @@ -135,786 +123,3 @@ describe("expandActions()", () => { ]); }); }); - -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; - 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: 1, 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__/calculate_move_test.ts b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts new file mode 100644 index 0000000000..193e793cb6 --- /dev/null +++ b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts @@ -0,0 +1,819 @@ +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 { calculateMove } from "../calculate_move"; +import { setCurrent } from "../actions"; + +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; + 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: 1, 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/actions.ts b/frontend/demo/lua_runner/actions.ts index d3059372d6..1d4cf96f79 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,27 +1,17 @@ -import { - ALLOWED_GROUPING, - ALLOWED_ROUTE, - Identifier, MoveBodyItem, ParameterApplication, PercentageProgress, - Xyz, -} from "farmbot"; +import { 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, initSave, save } from "../../api/crud"; -import { - getDeviceAccountSettings, - maybeFindPointById, - maybeFindSlotByToolId, -} from "../../resources/selectors"; +import { getDeviceAccountSettings } from "../../resources/selectors"; import { UnknownAction } from "redux"; -import { - getFirmwareSettings, getGardenSize, getSafeZ, getSoilHeight, -} from "./stubs"; -import { clamp, clone } from "lodash"; +import { getFirmwareSettings, getGardenSize } from "./stubs"; +import { clamp } from "lodash"; import { validBotLocationData } from "../../util/location"; import { Point } from "farmbot/dist/resources/api_resources"; +import { calculateMove } from "./calculate_move"; const almostEqual = (a: XyzNumber, b: XyzNumber) => { const epsilon = 0.01; @@ -313,199 +303,3 @@ const runNext = () => { runNext(); }, delay); }; - -export const calculateMove = ( - body: MoveBodyItem[] | undefined, - current: XyzNumber, - variables: ParameterApplication[] | undefined, -): { moves: XyzNumber[], warnings: string[] } => { - const pos = clone(current); - const warnings: string[] = []; - const moveBodyItems = 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; - } - if (item.args.axis == "all") { - pos.x = toolSlot.body.x; - pos.y = toolSlot.body.y; - pos.z = toolSlot.body.z; - } else { - pos[item.args.axis] = toolSlot.body[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/calculate_move.ts b/frontend/demo/lua_runner/calculate_move.ts new file mode 100644 index 0000000000..de14640ff3 --- /dev/null +++ b/frontend/demo/lua_runner/calculate_move.ts @@ -0,0 +1,212 @@ +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 { getSafeZ, getSoilHeight } from "./stubs"; +import { clone } from "lodash"; + +export const calculateMove = ( + body: MoveBodyItem[] | undefined, + current: XyzNumber, + variables: ParameterApplication[] | undefined, +): { moves: XyzNumber[], warnings: string[] } => { + const pos = clone(current); + const warnings: string[] = []; + const moveBodyItems = 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; + } + if (item.args.axis == "all") { + pos.x = toolSlot.body.x; + pos.y = toolSlot.body.y; + pos.z = toolSlot.body.z; + } else { + pos[item.args.axis] = toolSlot.body[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; +}; From 175db8f0e92b3a370d712f4ac6053e1fcd593564 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 31 Jul 2025 13:49:31 -0700 Subject: [PATCH 26/54] add demo account logging --- .../devices/seeders/demo_account_seeder.rb | 13 + .../demo/lua_runner/__tests__/actions_test.ts | 180 +++++++++++- .../demo/lua_runner/__tests__/index_test.ts | 270 +++++++++++++++--- .../demo/lua_runner/__tests__/util_test.ts | 46 ++- frontend/demo/lua_runner/actions.ts | 140 ++++++++- frontend/demo/lua_runner/interfaces.ts | 4 + frontend/demo/lua_runner/run.ts | 57 +++- frontend/demo/lua_runner/util.ts | 11 +- frontend/devices/__tests__/actions_test.ts | 12 + frontend/devices/actions.ts | 5 +- frontend/logs/__tests__/index_test.tsx | 10 - frontend/logs/index.tsx | 5 +- frontend/nav/__tests__/index_test.tsx | 11 + frontend/nav/__tests__/ticker_list_test.tsx | 18 -- frontend/nav/index.tsx | 12 +- frontend/nav/ticker_list.tsx | 4 - public/soil.png | Bin 0 -> 519075 bytes 17 files changed, 694 insertions(+), 104 deletions(-) create mode 100644 public/soil.png diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 0cb880b7eb..94df50b0f9 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -99,6 +99,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" @@ -155,6 +167,7 @@ def after_product_line_seeder(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/frontend/demo/lua_runner/__tests__/actions_test.ts b/frontend/demo/lua_runner/__tests__/actions_test.ts index 0b915b10be..d15ca82091 100644 --- a/frontend/demo/lua_runner/__tests__/actions_test.ts +++ b/frontend/demo/lua_runner/__tests__/actions_test.ts @@ -7,6 +7,7 @@ import { fakeWebAppConfig, } from "../../../__test_support__/fake_state/resources"; let mockResources = buildResourceIndex([]); +let mockLocked = false; jest.mock("../../../redux/store", () => ({ store: { dispatch: jest.fn(), @@ -15,13 +16,18 @@ jest.mock("../../../redux/store", () => ({ bot: { hardware: { location_data: { position: { x: 0, y: 0, z: 0 } }, - informational_settings: { locked: false }, + 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"; @@ -29,13 +35,14 @@ 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"] }, + { type: "send_message", args: ["info", "Hello, world!", "toast", "{}"] }, ], ); jest.runAllTimers(); @@ -47,13 +54,27 @@ describe("runActions()", () => { runActions( [ { type: "wait_ms", args: [10000] }, - { type: "send_message", args: ["info", "Hello, world!", "toast"] }, + { 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()", () => { @@ -67,6 +88,7 @@ describe("expandActions()", () => { fakeFbosConfig(), fakeWebAppConfig(), ]); + mockLocked = false; }); it("chunks movements: default", () => { @@ -116,10 +138,158 @@ describe("expandActions()", () => { expect(expandActions([ { type: "_move", args: [JSON.stringify([{ kind: "foo", args: {} }])] }, ], [])).toEqual([ - { type: "send_message", args: ["warn", "not yet supported: item kind: foo"] }, - + { + 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__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index d17db928a3..d0528d5b31 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -31,6 +31,12 @@ 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 { @@ -46,8 +52,11 @@ import { runDemoSequence, } from ".."; import { TOAST_OPTIONS } from "../../../toast/constants"; -import { edit, initSave, save } from "../../../api/crud"; +import { edit, init, initSave, save } from "../../../api/crud"; import { setCurrent } from "../actions"; +import { API } from "../../../api"; + +API.setBaseUrl(""); describe("runDemoSequence()", () => { beforeEach(() => { @@ -254,8 +263,17 @@ describe("runDemoSequence()", () => { runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledTimes(3); - expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); + 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", () => { @@ -275,9 +293,16 @@ describe("runDemoSequence()", () => { }]; runDemoSequence(ri, sequence.body.id, variables); jest.runAllTimers(); - expect(info).toHaveBeenCalledWith( - "Variable \"Other\" of type identifier not implemented.", - TOAST_OPTIONS().error); + 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(); }); @@ -293,7 +318,16 @@ describe("runDemoSequence()", () => { runDemoSequence(ri, sequence.body.id, []); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("text", TOAST_OPTIONS().info); + 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); }); @@ -391,9 +425,16 @@ describe("runDemoSequence()", () => { ri.sequenceMetas = { [sequence.uuid]: { foo: undefined } }; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(info).toHaveBeenCalledWith( - "Variable \"Number\" of type undefined not implemented.", - TOAST_OPTIONS().error); + 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(); }); @@ -409,9 +450,16 @@ describe("runDemoSequence()", () => { ri.sequenceMetas = {}; runDemoSequence(ri, sequence.body.id, undefined); jest.runAllTimers(); - expect(info).toHaveBeenCalledWith( - "Variable \"Number\" of type undefined not implemented.", - TOAST_OPTIONS().error); + 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(); }); @@ -674,10 +722,18 @@ describe("runDemoLuaCode()", () => { jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("false"); - expect(info).toHaveBeenCalledWith( - "API call GET /api/other not implemented.", - TOAST_OPTIONS().error, - ); + 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 cs_eval", () => { @@ -719,7 +775,16 @@ describe("runDemoLuaCode()", () => { `); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().info); + 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", () => { @@ -748,7 +813,16 @@ describe("runDemoLuaCode()", () => { runDemoLuaCode("debug(\"test\")"); jest.runAllTimers(); expect(error).not.toHaveBeenCalled(); - expect(info).toHaveBeenCalledWith("test", TOAST_OPTIONS().debug); + 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", () => { @@ -758,6 +832,13 @@ describe("runDemoLuaCode()", () => { 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", { @@ -850,9 +931,13 @@ describe("runDemoLuaCode()", () => { }); }); - it("runs find_axis_length: x", () => { + 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 }); @@ -861,17 +946,21 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 0, y: 2, z: 3 }, + payload: { x: first, y: 2, z: 3 }, }); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 100, y: 2, z: 3 }, + payload: { x: second, y: 2, z: 3 }, }); }); - it("runs find_axis_length: y", () => { + 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 }); @@ -880,18 +969,21 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 0, z: 3 }, + payload: { x: 1, y: first, z: 3 }, }); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 100, z: 3 }, + payload: { x: 1, y: second, z: 3 }, }); }); - it("runs find_axis_length: z", () => { + 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 = 0; + firmwareConfig.body.movement_home_up_z = up; mockResources = buildResourceIndex([firmwareConfig, fakeWebAppConfig()]); setCurrent({ x: 1, y: 2, z: 3 }); runDemoLuaCode("find_axis_length(\"z\")"); @@ -899,11 +991,11 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 2, z: 0 }, + payload: { x: 1, y: 2, z: first }, }); expect(store.dispatch).toHaveBeenCalledWith({ type: Actions.DEMO_SET_POSITION, - payload: { x: 1, y: 2, z: 100 }, + payload: { x: 1, y: 2, z: second }, }); }); @@ -1141,6 +1233,102 @@ describe("runDemoLuaCode()", () => { }); }); + 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(); @@ -1175,10 +1363,16 @@ describe("runDemoLuaCode()", () => { it("runs non-implemented function", () => { runDemoLuaCode("foo.bar.baz()"); jest.runAllTimers(); - expect(info).toHaveBeenCalledWith( - "Lua function \"foo.bar.baz\" is not implemented.", - TOAST_OPTIONS().error, - ); + 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, + }); }); }); @@ -1223,7 +1417,7 @@ describe("csToLua()", () => { * [ y ] api * [ ] base64.decode * [ ] base64.encode - * [ ] calibrate_camera + * [ y ] calibrate_camera * [ ] check_position * [ ] complete_job * [ ] coordinate @@ -1232,7 +1426,7 @@ describe("csToLua()", () => { * [ y ] current_minute * [ y ] current_month * [ y ] current_second - * [ ] detect_weeds + * [ y ] detect_weeds * [ y ] dispense * [ y ] emergency_lock * [ y ] emergency_unlock @@ -1252,7 +1446,7 @@ describe("csToLua()", () => { * [ ] get_position * [ y ] get_seed_tray_cell * [ ] get_xyz - * [ ] get_tool + * [ y ] get_tool * [ y ] go_to_home * [ y ] grid * [ ] group @@ -1260,7 +1454,7 @@ describe("csToLua()", () => { * [ ] inspect * [ ] json.decode * [ ] json.encode - * [ ] measure_soil_height + * [ y ] measure_soil_height * [ y ] mount_tool * [ y ] dismount_tool * [ y ] move_absolute @@ -1282,7 +1476,7 @@ describe("csToLua()", () => { * [ y ] soil_height * [ ] sort * [ ] take_photo_raw - * [ ] take_photo + * [ y ] take_photo * [ y ] toggle_pin * [ ] uart.open * [ ] uart.list diff --git a/frontend/demo/lua_runner/__tests__/util_test.ts b/frontend/demo/lua_runner/__tests__/util_test.ts index 09d35e0ea2..e355cbf7ac 100644 --- a/frontend/demo/lua_runner/__tests__/util_test.ts +++ b/frontend/demo/lua_runner/__tests__/util_test.ts @@ -14,8 +14,21 @@ jest.mock("../../../redux/store", () => ({ import { csToLua } from "../util"; import { - EmergencyLock, EmergencyUnlock, FindHome, Home, Lua, Move, MoveAbsolute, - MoveRelative, SendMessage, SequenceBodyItem, TogglePin, Wait, WritePin, + EmergencyLock, + EmergencyUnlock, + ExecuteScript, + FindHome, + Home, + Lua, + Move, + MoveAbsolute, + MoveRelative, + SendMessage, + SequenceBodyItem, + TakePhoto, + TogglePin, + Wait, + WritePin, } from "farmbot"; describe("csToLua()", () => { @@ -55,6 +68,35 @@ describe("csToLua()", () => { 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", diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 1d4cf96f79..825a736073 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -1,17 +1,25 @@ -import { MoveBodyItem, ParameterApplication, PercentageProgress } from "farmbot"; +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, initSave, save } from "../../api/crud"; +import { edit, init, initSave, save } from "../../api/crud"; import { getDeviceAccountSettings } from "../../resources/selectors"; import { UnknownAction } from "redux"; import { getFirmwareSettings, getGardenSize } from "./stubs"; -import { clamp } from "lodash"; +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; @@ -97,6 +105,7 @@ export const expandActions = ( args: [position.x, position.y, position.z], }); }; + // eslint-disable-next-line complexity actions.map(action => { switch (action.type) { case "move_absolute": @@ -122,7 +131,12 @@ export const expandActions = ( const { moves, warnings } = calculateMove(moveItems, current, variables); warnings.length > 0 && expanded.push({ type: "send_message", - args: ["warn", `not yet supported: ${warnings.join(", ")}`], + args: [ + "warn", + `not yet supported: ${warnings.join(", ")}`, + "", + JSON.stringify(current), + ], }); const actualMoveTargets = moves.map(clampTarget); actualMoveTargets.map(actualMoveTarget => { @@ -130,6 +144,82 @@ export const expandActions = ( 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; @@ -178,11 +268,19 @@ 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) { @@ -193,13 +291,45 @@ export const runActions = ( 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 () => { - info(msg, TOAST_OPTIONS()[type]); + 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": diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index 65b9006a44..a533ab0616 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -13,6 +13,10 @@ export interface Action { | "find_home" | "go_to_home" | "send_message" + | "take_photo" + | "calibrate_camera" + | "detect_weeds" + | "measure_soil_height" | "update_device" | "create_point" | "print" diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index 6ad6b30904..7978e334a1 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -12,7 +12,9 @@ import { LUA_HELPERS } from "./lua"; import { createRecursiveNotImplemented, csToLua, jsToLua, luaToJs } from "./util"; import { Action, XyzNumber } from "./interfaces"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; -import { getGardenSize, getSafeZ, getSoilHeight } from "./stubs"; +import { + getFirmwareSettings, getGardenSize, getSafeZ, getSoilHeight, +} from "./stubs"; import { error } from "../../toast/toast"; import { collectDemoSequenceActions } from "./index"; @@ -100,7 +102,6 @@ export const runLua = args: [ "error", `Variable "${variableName}" of type ${n?.kind} not implemented.`, - "toast", ], }); lua.lua_pushnil(L); @@ -173,7 +174,6 @@ export const runLua = args: [ "error", `API call ${method} ${url} not implemented.`, - "toast", ], }); jsToLua(L, false); @@ -206,6 +206,9 @@ export const runLua = 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; }); @@ -277,28 +280,34 @@ export const runLua = 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" ? -9999 : 0, - axis == "y" ? -9999 : 0, - axis == "z" ? -9999 : 0, + 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" ? 9999 : 0, - axis == "y" ? 9999 : 0, - axis == "z" ? 9999 : 0, + 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" ? -9999 : 0, - axis == "y" ? -9999 : 0, - axis == "z" ? -9999 : 0, + axis == "x" ? sign.x * -9999 : 0, + axis == "y" ? sign.y * -9999 : 0, + axis == "z" ? sign.z * -9999 : 0, ], }); return 0; @@ -356,6 +365,30 @@ export const runLua = }); 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) { diff --git a/frontend/demo/lua_runner/util.ts b/frontend/demo/lua_runner/util.ts index 42f2f4094f..9f3d96452c 100644 --- a/frontend/demo/lua_runner/util.ts +++ b/frontend/demo/lua_runner/util.ts @@ -24,7 +24,6 @@ export const createRecursiveNotImplemented = ( args: [ "error", `Lua function "${fullPath}" is not implemented.`, - "toast", ], }); jsToLua(L, false); @@ -120,6 +119,16 @@ export const csToLua = (command: RpcRequestBodyItem): string => { 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": diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 7dafa78301..a74f50f095 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -321,6 +321,10 @@ describe("execSequence()", () => { }); describe("takePhoto()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls takePhoto", async () => { await actions.takePhoto(); expect(mockDevice.current.takePhoto).toHaveBeenCalled(); @@ -329,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(); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 3109b13427..a16539179f 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -246,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"))); 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> { ({ })); 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; 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..307748ede2 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"; @@ -60,6 +60,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] }); @@ -262,7 +266,7 @@ export class NavBar extends React.Component> { 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 +302,7 @@ export class NavBar extends React.Component> { TickerList = () => { - if (forceOnline()) { - return demoAccountLog(); - } if (!botOnline) { return generateFallbackLog("bot_offline", t("FarmBot is offline"), lastSeen); } diff --git a/public/soil.png b/public/soil.png new file mode 100644 index 0000000000000000000000000000000000000000..f2e072f21ae477d76bce48258aaccc27bdf84de8 GIT binary patch literal 519075 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

7l6bGS%Z~!;&#Un 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

xtcdN>DvEd%)QL%NAtc+-SN;%~># z_ZO|8IDU?HAeYc^l86vt;g?{u)3^|IXADBQE7oAPd`^YIjG$Z`M7SEC$!0OjYLfnXF) zU6xN;-OPSOeptNefzR=+0s@W3#-o5e#HfW^z~IOrO1OF@<34vnAJ_O&WS{l3eIv!Z z;=aG6^$7U-0Vo#i@$nMX${LKeDvT{<|8E;aE{66aZbt1-Yn5bcUtf@w{K9W$!~gt7 zg~;3e$NlBv$t0W@)P9Orgnt@wC=q9GfozFzYmJG$U=-&IKW`fVtE*;BWBY#$Rg^xq zN7)qv&Kb4i_7ugKj{o}fc}Td|yv&e1&Fhc!*|Vg#eGWq6(2R1nD(J(=;BEalsz@xG zo;umj!r{%`O+cM#DF1eL$r+R>V0l52kxRO5X!x#Yc3ljs!J;LG_o>5n_vHyzZk;z* z(9)s@I=gW~Y(xppej68Yc7`YpDjx)7tFpo4Zgo%9&ibJ5mZz7mZ=orK8zFAN4q2Mn zr|}QT+>D9_;J8A)_z#8Y-Ssm&(tRFyJSdVizlp`*)XH=Xm_iX&xL1N+Y$)ClOZ>{K zs9%)TVpn2-!!&Zu(g|2dF!g-;8=24C2k0*-1N)kY3VHdYHh7FJHF0opjExC$(YD0o zeTnZ7CufHC-RES}BtPkN_;)I-w=Le!Z9E<(cWRY-lti1+)vz?_YT+61>M4ah$)!+k zfd_ZHn92%o9=%pFr{D9-rwNI*{I~L?pUv!>BB>QfS6tJ^F5mvwX(x#|87W%?2uSbux{k(E0Mk^xGo~l> zvAo<+h$W^8ESlkk-In34y+FV-BAkM{W=c`xnB@L%=A_5QW$&uRke#I@^Hb4C?Tm~N zXa@}w9m?x{jT@O5#<80FI3$f7JR%&QPGjj1Opome2%wIqG6C2JmQr?8%L?Pe3r?Q{ z16QVZ6g&fKQ+sA7!N=DU3_crH?heP$W6cc8)m#>5Gzp@3mX!3f+Nm$P z`Cc{Sfr$cfnFjtMJ}E}o*g5X6+bOpi9+$(>zL|3d0~~7Uhq5lZZwrIT3c6np4Gn-k zj;2hu!`XFklL@gPmhv6Ii-N+!2}kL$T>`iG_;?sm-=?B1g6va}VhR^ikiz?lzhw)e z%sbt;x4VlfR?4i>%?kC=CJlxEi>n?NtMLu=YAdIOjUSW`zw3RJjGRAQnRHFWQU4l+5lE{Uvc5_T~Z48R4Se#Fxr#P71{QI{JB8md9sY+OXqly6Bs7gEY zLk^RRk4-(sm^Rl}rV0v-Beb44I@a|5{JYsS;lR&?L?hJ4$LQikt;p@}|AGtGm~(WS zj?xr(O8ZiE<7_*;z4wS9ss$Vlt}$nA$LFG&KK+#MM@#@1G+T5# zBXr9f9NYurv=6aMo;k|Ad=|Fks zO^^&ClXve13{sXdvc91_pX~ee4st_#EraOo?p+uxd7Kc0Uu7KBg(R6LcOHi^SU5So zRDm13=Hl~`__pAySm_zWuh`jgUyW3~CY-_CytTX>u1lszmZwMU7_XYHc}qu-S@WVx zC`g6i2v`dr6KtnwY)AIrgwdHjD+p!@*=GiqGvDB8sps@C5PG@2bK{wj z2le9-9^Pnu1h4LmccGrfPRC|nv5x>p3tuB#U0_b&{TQ{&)Xg5Dk@|}g#=P&bNw@ok z+&A{b)A`%*Y+NAvp~-?IWuPVjE8r3I!L82o$a}n1e7cpH-A@Mvu^m6Gv3g z1XarL?AU$-l}`@P;K@+6jQ1KauM|zVu|XrsVw01U6k!iWHYBtSZl=J%eT_>vch%MT z>Ik+>?BD@NcKtyNs@#lBq1bZ?~#@45JGqml8`Mzk|fC{gfa^m@A39;&r|vR?)$pF zT!Nt>^{we|kdka_K|$fa%wn)2E?38R&z)qJ37i%A{Eok}#Jsx~gdYd$Y_F|S{U^?CbR%f9CE^T%Uh{&^IDz{;#x@lPiskbbbX<^n5X zsS*65_(;n+yg8VV%aUCqS?5YCkNqlgnEM|oH?AidPq~BjPKa*sb^Vmm8$SDAT;jJ{J_}K6&}tyuWe&K29!_P3gVs~ zvYe>DpC2e{N_e}RoxwVOoV8tSlB(9iBr3uVHI3xniQn2^lIoCg{)^gO zI^wlE57jn4{=gy{3?SG!4sKCCur+!?et<}_-l6k`@?Q_Id%qZC;@W>FmW<%Q8R`$s zMG80hEMNT}sRooLpDM(jdMS#xjz1T$G?{hY939OVo`+2lPBDsM3B7a=r1;z;Lyv|1 zp>fvG+$*eX3H32F?;-ROO4A`e%VQ~h_N*!~$lq>!(Y$5~IjIv83>+6ou*fA2UgwC{ z?6aq(p`~Rk4R2|IrK-HP7DBr2?h`P5QGXnBaI?4nH8V3aK2B8Ga=-515RN76K>ag* z;0XemnPndDgBVL<#1HuY4?CBZmJi;cu>{wGlsJEupdSL5t*z8J!h8XC?R*2Ja*a|e zVV1JN1v(J|(i7L;PPXl1RHNmKM|8a4gMN0l>~?ruforj&iPub;NJw*ePlI!W&d^F~ z%c+?R_-%A<1${E&;=K!xu;6~bEIV#!EyZ-pHAS^X>;P48)1D}+eUS<#aJ8%kb=|wG z?#jLf!UR1GU+gqZEGmy0WWY2KG1gYX6dpB`%oRLd%z9i(Fy zQds+obfutG-qJL20lWty%LvXF3>AwNbC;bqphyDpfG5T$|FGg0rsf-bmQJ^5Muka( zAxX-<0HRMPflu(ZeSzU|C;v|`iMZ9J6p9QHW_K{^-5%N6O7jDU#bO3`kMpP;n%&=P zn;1b(?GMkBXbnr#ZYm2xIrb_OnhixmVl{diVOK*^0V1y>aiJYH*Nu(2 zZn5wBfMVBTG0}q(Y$j^w7xdT7Q)GR6X}s`%_q>9qeJfuCQ!V3Mq$?4Uiv-Vu#7JtNYB@u3{w* z@3_46a zS<=K%0_yX}CMkb5k7fK-C3h`2&AwmQir8E%;E|qo?VC5YWo*ZCWMj_ah(df#n$Gm^ zt+YB`hR}J}e|W^ik_?Vc)tVmqSE0a#zs`L+?LMch+ajh&bSb>o?PWQ=S7+!CkK)la z63$)@+)rhOH8CeACqseU)XuSZ3g+xWBshebTYGu^nuwBrekQZWdeMyc`NTsea*{pc z`Uo`W^#5+tcz{QgVeqx4!6l|Yc3G;6c$!${X=ouhPou>X6R3c$Klr1qL}tTP@ioJyQ=)jx4S?_YPZW) zR(Ol0l0W1;AYfIPH)NkcCt`TX>`-2k#=Mgh6PvSb8d4-*%Q!sSQz)Wm5X zvNFlr-2bs&-TB|21?*@|ixax1-ZAntTro0I*ou1k3xKAA8QCWD_ZV4AxHNNhYlyPd9UF@v`UT6MTnM4#Saq z=5-zB{Qp9lQ?vwlL59l-zdWsMUy0cmdP<%<7)dJ>407m@5QWZ!>)>3PN~hNCR(J1! zffuO-2npEPd5it>0@>z~RR(in)*4dicF=>Am}p$mGu34dKKFPo;N;=pW4rffWKz4xWw_dnsz4Eu z+gZPnX|@G4e@0+ZS{j_#q7LM8xa3f>WyxhM1mm5gU_VbmmTB%L{#yNW%HtdlZ4d$$v^u*5DI#$9j>w@{&kGo!ip* zys(V;#7iS#^u>L)F8A)m<)T1TNL$u}4dRJ(LI;Qo2(v)^o_6%T$<0o7k~S_oz9-~l zqHj2zUS=@39c}wOxdLW}ky36SJ^6(YlXWbrYD|lJQ zLFeea`508nb*2euWhQO<6*ih*4wrRM*1J?$KpWcwYBpo@f zNnWawiRaOeyI)#Nd3pL^evcU$5#eXsEmadv6(;ty)XVQxUq7%|4E3ut65a~H;|O2- z(^QKtNYTM|LTG%hf-weMh^71ZlI{@FqUT-DBsfFEyyo6+DaKASv4u`PM;}gsS8SKw z8nPWV5joH>lo*OR%NyJj3Uye+E<<8G0+#v6MtGet^GX0@cz!(&dAe=yCQ42IgC`mz z&67-165Z+YBVWH_7L0KN3Y0jq>kz|mL+&yj+)+CR%#hHUvv@`#_+^LFEn3xnw zzC_;d-uhG^Zgy~F=MU}&6&|QC{{9~M?JNV?WU>?`>6cmO=#NUpAn_Xr1&U);S~e$6 zh;^Y(Zr7sbrXSilc!jGuvPm2$b7^zVSl@<%2K#2tv+lC&G|^RB>4V53oLiG(ZsDxn zN606w7?PrU=)L93r964IN6ZvQ=QHqAhUDOnAR7kF^PCEgYx*ad_hV-{Y5&3T>9st$ zU!11-5J_?M+d4B`9}OCc5xjVGbzn`P|0(wt(Rq;NNs*;nRxh4E3k2o%7g ztk79~-8knGv;l(3jK!kIl#}!@JHlHct3iXv_%D&X;01Lkf z#fE<9d}qxRm~n8xQ$q2)f%3r~aU!ng*!wKO5Y?1gx$2{MS zzQ(vLi5vkZlk<&sV(Z&GK&RqxtNF7iC-z@UOAClNc6N4){KIwL-ZnPaXYuI|I0%-2 zag*{K*>mSRukBlxgfbm~$gO7R^zXRrZEb+J0Qm+`JR6(+3o!Y~NlW|MZ@sU+ivv`b zjZhR`pT)VlojGjEcCdaM+lQB3>Ozuibd*@IVu@aHm%fH0IU&=ueRqU6C4Iy#|wG4pNI3{HGaR zOCLIHPHuE8By`uB;%!4AtI|v7GVuHF-uN2uWM~|^IyMGZQN0-$K${(PY-1coWApI;Y-nrSn@c04BgD(vMGFC-WMIv7$NL7()`#0}aBA@fK zO{G!irj|fc4sd7nmA`g!o6s;rEWViWlsNYyjEjUW{Xw!sBS3JEfQ zxo}v6FGxgqHgTptWTHU>tpLG%C?@K*Qq`Isw7F;6r=Ntm5<8@69#GI2mPc)g+ zyL%U0k4-gx`^j*vMt8`$74*JN9Y~6f9B4PQHaGu}uF;I2Fpfpi9d!Pj6AwdOS|*k< zb`T2ETu;BI8uAw*NO6pJ1&6sUFJ!;Ys+W?Ia;P49d>C3XJS@8TBqQn}`zmfQW$R2l z)XfEPu;jkT-MDsx;{G_>c$d*%1K$pQw`r$S9C0YXKhRy$;m6xCH z8awSru2;~xDxdf^Cj6~0!me9;}4 zu^K(YpdMInia~IIN~KuBA5I(^fhiPGDNK?G)Bq0)zZSk#U}&`0Ec^=V zB_WPLayiit5j@_4296{C+bAHx>|{@RtQ@CwrOR%D}D*HgKZbipIx}n6+ zPNSdW=Z-tFZ|>6QFL2+7-niWU7Ahew&WJ6n*uiLq(5-xfFIHuA58CW}7vpnS9(?%n z1(^(i&SoCG3+$%U+Xwg!YSthB);V@;=D0#VxxM11JKT3H1%?IKDge{dF_nDRbNkRe zZtn`?eg80Jb*O}dm}>!AMWQ)?Eb%x8q-oz~U=4kK_wOIQbMc zny$LkEaWSg+otKH-hESDG8It2i73zC>%k{K|SIJwH5{AwYWsmRvk;~Bmg zqw%A3xP@uF6 z9?jy0Cq}by1+LCwfAyFJ6~Xk1_OVej=;DVyePTHLT!E`?%4y3L)*`GwX3H+ewTF1z z`nFD!`aw=Rge(co6I3e<;_#8vr{X~}Ek>tw)~`8q6N*>tMkiy#l!p(Jv#R;H*M4BE z?-+rAA`Rndb6)6xdYrd(2g zU!!-{5^U$KSXeHLTnxo>u5JWbc;mBvEk8S-Uu0-M@Vc(Y+VY<2XmD|m8&~TzN)=mk zlqsUDd{QVC954RjNF^aIvz7||=9@I(w8^vPC52Bjb@bu|Nq=cqaImp|UH^>?3OmVr z)$spVTwj+}Jkzd+!RsEd4doQ(lmjhAhQ}(2IFCOiHOSgJtS!(B4N=^YJ%scK1*!kY zza3iT%mHNQIatZ9^nzXX!Ny=2RNAruq%f%P^aLOFG7?F+J6wa#9{45?(pS;&$iOCc zn3J=puyAXkZaZ>!}H)Y_BSkhR8Mz>px?jC%42bq@IxtTqUO`0a(seo z!qHmb_T8cXDZfK`+&tnbPfXkN3QjAWM3=96Oz9KGML>tM-sRCu{;2Rk73q)D&n1l1 zp0YdHk8ugJFkW>k8<3dki*p>YmUxSGp7+kQH_a!S#^V2Tm*P%WybQR_!YV91UMDsbjQqw;7~B-oWkn0K_R}jd;8;b~ZB3oH(UF-&dQ!lp z|7#vsV@Ve)6{#t1UIdsWQBspO{roAEp8fya70VRDdICY$?ypl~JuB1QfB!_S$waE~ zoMc@+cB})P&sLJz1EXAg&uWYCt{^T3{I0S3*!sc?mb;ikUPBV^JH#MVncxkfb=*r! z(-P>NojuCO^pD;4;?}-AQ#wh8@^M&pPMq*wo4Jy2e7Q|UrYPA{yK?Y2LnyYd$lw#Z zD#mulHYcL>x-~t6!KBkhLG6ur`hNQOA`(Nc9$w6=rci9x>`0cPg0SutiAuj_dFGnJ z&R<{0Dg;vYeQ9l7YYC)dI!(t7nOw&;pNpO)#gLPwuQrWUp7c)rp#V_`iH}#jgQclBX zC6IIFl{}bY7J_V%N}Lc#44<|()=^s}%80q>!}-b$Wc3B$)iFOlA-*rr zghDVl^J-R)uDRr$vI7)>Fxp{aJ=PQqwZgZ9!A*L?EH_FY>mNmrg=T?{d~q{#&QS0a zLv*i9 zk+9^Inyx-QtyFka%hjas;@NNwzHdg?`G@f%?2)`oO+PW1BbRUE;IFTFi=l*5Ck1#f zq`i^WyQ51Laf*cyULa&F*WCZjIeVMrQgYW1kljs@z^gdBu~CJrle1^{K7vo5h@Q^Y z`ZHE(HDc`hr8Um6cqrh%e~Ss}s~#Lraxr3Sya5XTfX{#lJv+=ONUB}=v4-xnH3YVI zt8g4g2-`ez#zWnq6ZkZM9{^o~%N8-QAX?-W&Z40yHm&^z=j_)B2lrH+TwPHEK_(0x zFndR?@%^I@@6l09Y(rol+$1*?52C$X0{jt}2nxH!D~Zq0u96;CE3_)xZ*t+|Y{l*V zdP#=%^XdIXy|d4H1Wl?HxH95!y92X0ejBw9)$=@_m>QjjV>K5+ruv$p!PmI07^evR zHoo*B`;mcxb2(!7?qzBT+;eqpWkWv3b?(5Fum;4J{y$h6Spxj57`PTqjHU9jUrfiU-14qhtOe zsSA(Do}%-~lP6T9O^Bx`Lrq5?sxtC#n09`aL!*Bsr91ccW}tm zC7*Yc<-B|MZq_1?^knVdVVLrl+`faYh=j70ZuukE2rrTG9nPNL+3~9F`DD`vO2Y#T zoRJ3GaQ$5{+G#W;jx)W1iN9&wx_|#Yas0SrVT?}OrERP=HFFxd0jmdf2C2jM`4;18 z4=;MYxCh7usxbT#NkG}AI+1^HT1#*yyafXAiSVZrMlXhJAxa$6$OC;$fJ2tjN3uCm zS8#Ph2^{^6-gjU$J^A7hP)E<9waQeQX)rgwjhm$`d5lAL1zO8C(6h9pB^> zwG~y!uZV7akLZi|$+cYG%PRh>h$LW{dTYni7)od8C>{sn;GIM=^VX|1s29j!#F!2p2cyRO^yT<^LxT9yjvuW zvzYN@=^c(z$EY%KHFJ@Ef@{wg8|vl^)DM&n^{hBc?%(epbHJR>KTAX6sYG(&-`_)u zlQ%1jFHzX6jz9J+$J(|xeMjQyd!U8Y(;r3Bdvd_K9fh-^kVw?%@}$9sPD7Tiz1;qK_DcraBd)5z5H zxi3@8ICA4QvD?Hbs7)eN15J5;-_ND>a3VqMio<+FpBddD575sy@*#*EGkfqr8V2MU zb@^xF@ok;xkQ$KHD#iXvOJD`;BAlLmw!7me-c~E@B~bFg&0>zIy+MK`xS-jt3nu=>6|`a!%%c;H{2VwF;e$Z$&vL+^xS7SQ7I)YH1TFIs>lJilWy%Rf&DlDQ)8jw>!)G;JNkANYUlIdltde75=4Wn5v5_S@$) z@@uV59bss1FvDIx-f6#p8tiqQI-R*1e49Uty^BoVk5QuEkvJ&M0Wt(AER33O>gu8t z4U7Bl+S@Zw0eR-)=0;v6l1L@;;R4Tr{T0T!2A)0YA9g!7BEGE6%#gpNoxcS>^H>vE zDiweWXLoBo{&LZQJhezQ-Hju)Y+#ZE7zPaH>af;Y`Mon#2Z?^j={u5#j}F3H1K17)^15ynPin8_Y#&Rz4I1^gd4tw zlas2(2D*}4N&e;Tq8;XeuV%HNjb4r4ry~g4; zVRl}H%gETosaq=K2X_SL3Uj@aWD1TgoJE@z+9p5Ho{VMb-~L)C8E8WE^huZ1QC%1p zvdq)(Z7uTg#A~QGi5SGP7BWTpnvDKjy0Z|4Sn3M!xV%*D)Cz7l1Wa{VFr$gj`H$gDSU+&$O zS=j&$4GpXz0N)2ZQz9lxIl^M$x2ttT!y-uPRLRGZkUW8So?b;K= z+a*TA+xC>>`{#C@VdZW?jF)VIa0nuWM}99WEnUmH8YGOFyn+R!Q|UXibtpb>?&&S? z>!RS~p+kp2T7u5Z+j|#(iR_+GkvCmeYD{K2i+ASn8*(!OIdl8#noOutR5DsD%=Oh- zwc0G?($(A1+d_fUFlNKUnWLR59?cwmDWZdT*!IDLBr0J(%9;}GA+hyb(kWGYF3#9;Lvsa*X*@CHhfio8TenNhvaQhcV(Ni}c71Y^q*u~a(@fsW zF7|UGTmHXnj=Ax1C3c512g5_2L-bQ*r7lRfe_xhj-JorhszsGyK|li9#6Hc1Vz2c$ z&$p~pou^XbZHrD#IMI2BncVd7xLVxb-PsB2ymbm@lxT}E&#@@gc)&RSc(mA~e{Axj zw7C5!|GT>|6-2uez2P}~_tihK{KV%+@(pB85k1R?9}7bpl3y7PV?Z+P4>lzm2QLUP#+u+7hW%^ziG^TFm!Tk&+{5KhrzR-(d7>u1j?N@U zSJ12moj1vZud;(0W3Ln|l|_4}C6E5mDSq|8v=-@iM2X>UZxSP@q7a~lfT>l-Pw_QDf~?Ov__ImLE-^OoP4bqZN~_3A4CHke&@qx-og^WZNk(i6Ks-_3g3Je){2%^mke zt2n$Td&U?xG2-W%RsQf(auv>mD}t;D&+*|$AG9m|CKq8!7p$0^z?aC8SH}L5CkBwH zQlr#%OXH$fz*vx6xb2QU`#w9X-rI6IM*p^$Tg znu}zKFKjx`nh1zliI~NXfaBpHDv?B`vE$~cXMdrg6q(05L2|cWbfKV8`GAWgvTF|Q z#-miuSAz?C6-|m9TO@{)SM91y@3aY09_ z)kQBRb;+dB+_){0fG@j~J)pGKZOQMwDg)iQC?aJ{EeskQV#GyyXdpxPUH(``cckN8 z#}}zN+VGZf_accibIN_}k=KPL^*`;8z5$b7Q1AY)3)Gz7y%*!}k6_%>J>~TOyiR2= zB)nM=RlPWPD*7|TLmkdvEF@93By=dQNIr?OtzB4ogCy>DOVA_{3s`>3NbRk?*dI62 z&20!0>?_!qUeH-VVG9WfTVznj6#U5TmdlU}E-um?Uo9`c<(_|xiDD-g@E0l0C`3*j zWeRgIYFxJ#Wew>$m)o0u)}4)FpGI3_t`6_MiGA{izBxgxbqaJbQlb^?utgZ@XH_#8 z307i%CV~TzLN35HHoJ4-s8Bh&y;q?K~G=Khsv==lpEv0GG_@$Kt4`xnRFqDy2X`v`VbaG zd*K}MM8#P7?rgPF#vu`9j+vrLCYEEU&zI7pddh;KEzig=A=;b9?dA2RJdlC5?SI_yW-^ z@^1GvSn$wU_QqEnn#u?R+el9X2m4NN3qbMCYECf;Sl+AR6 zpRhh6aY`b2fh;wy1N^jY9AT>fqzPfOM?Z7pj}*}`9jB9%mBlm^C(iG`7gI30|2142-uzo#ep8Q&ZqdDV zu@D23KHDB;2z|W| zWGgDt11v0NkKAjA%Wic`dA}9_5eJ&MPmEbT>uBh#5hdf5(^Q=qo4JbyPWv<=3=M|{b7(zH2-T7o>efg?Yocpb5=5@fva2Z)XX%vnX z-6ug$MM_Odd_0PvCiRtBm4--e!pZNXsbt0v7#Mtp1gD=>DDyFl=BuvPE4LbaFbwmq z$NtFol&h{tHm)e>>uz8EBJaJr)O*?EZXKGwLC87b<8>h(eis*a5vdaO93-U4uA;1T zbxL$*wWbB9WRU&7L;Q}j``~5rKLm;R+>uM)Ifh{btf?=nVwJd+UjnX%Webu-U+|7k zP-bQ*DY_N**#neKsF|b02KacOa-bwqSLIF<~np#&&*%Sk-KUm9bX6r_7I)Md|&w| zCOq*8qLbQE2Pp<2W`;MinX7`=eEww``VMjsQ2Yuj;g<4pqLz*)VD-@_r&9kI-hkXr zK%gBfAp9{Be$w&$Bqb)jR$>Qz^6l^_`=(tuCzwh=2_q)e`I=Bx3bbd~7wQ_u)<%Db zw{(gh$SxZ!%F%a}V5{B!Gnlvyu?-%pqZm%z#wt-FulvIXdwcuXY9*FUyw96I^<|iE z2|%3k0C*5@;m7TLVtaPd#7LQ_LEjTeHy%eZJ=yb`tpB@`PS)UwrQ{6Rf0ltWv}zMQ z1j!h^#Qesgi8c;CxQ&oia{3wC0Wc_Xodte9$F9R#;@rmcpM6)=*cJ5Y9(^^*6l42i z?tSiA;`0Z9d5$*_dLYW7#bR~iJL_5UBO$3Rb}aA4KW#DP5WBX{S(FVd{*iM0=l!ZAV@RG`i0zCc_`)6+Fz_v9YS#qKiMqa zP1)7qVZrws(3I$dW#M)r3eI?|yI&3u56cT)W{cB1{0pRDF&?P?P}lbih`2^F*e+pcz zk7OH+XK+tR!hA zm_3sOjtoX1uz>VGf#S>5ju*}Bi?TD5`8lp86D7|YACGk-ybqv4H;r-8@4F#VF>D|` zX7JW&>OHKG%j_0XN!@OL+79mRQ^3*2DiHGND}VtYu|51j`hCm@Pro=vO%K0`62CSqhZH-n$RK<&~71 z-@E1Bx_u$^NfX>o`e&+x(}op_^z#hdOO3wINnQ~%Vj>|T4B~N)fqMXLBJDW{`T~_i z62kM&Z$$#{RI@|mYbG_bbiJhGM)BtR%WoMd0tNR&rJRzUPBz+d&Hq~b(+AF?+3WPL zMpqtSK4DCaU6q5FvqYm$~zc8WGB zgB^%~4Gj%YI2F@czj`;pEk_u*+ulB4DK>t>BAzV@i3g2lAH|LWe{Ueva$Ym}=+XhB z?j>O;C-jL`GuPg~4}(q&6#cA|tW5^ z8F5C|r6!T}bV)l7I#45iq};WTfRqPv|DVn>mnHihzdI&CBLpckRI+=AAS5C3bvr55!ANV2VD`=0Uv3giVf*}hO7unO#hZ(HYkiM{S70xx_c{!uZTpy5!xTQVM;p!T-vN)v1f~P;Y2`#Kkl}Ycfqrf~kNVRoZ zoEhU&)$2^}q-<}~I(4W>V9~pNhlzYVsUcZwLdJttiZvizg@@adiLZIeiSXK|OF&|# z>P%y!UW>_apDlqZFrj^HTU4^WEyB-K+$J0Q~tAA{vmdn zkSZz?_A%={P#Ed6WnMqZ8s8;Mg?SK)n=8e!9o@!F{~(8feX2Ya zk@p*5AYJyxek4W)0VG95MOa=)hxgsaA@Cm_!92rE-Suy`X*uOy!w{$1*pt!!= ztm+F4l@8d5GVH%?0`c93ZNFu?5R-m@0PGN9foKI;Bh|LjqAe`%iwX}{-=}?%LK$&^ z*)`6Cl7MzSa+y87`&Q*(;zEda->mbxShP=!-HRUUW*$P~rSdvv)ky?}50odq0_-As z9)|#J6c1bchp}=nA@dC^OIfjY!eB?9S?LJLmC+DSYvg9#zkixD3YSO?--hS#^a^5r zi4J4Yjlw_g7%9=@J|KG}k`WIup+|cR!Xfpyd(#KCA6RPh#Kd$M`1(YR*;!c$utnPc zW(P|nR3%oEpPE#ei%wyR3+^tbP|c$HqX<1jo)*9oIRAUBJt1snBj?*xDFWJ#K{ndK zkFzWupJ2>09IG8uZ!iF@#v&bkL9YR_5a}oaOM-}aV|WzoNx1S_ETm;+`P^9oNGbP^ z{QL4}r}*L8+}zx;=KDTj+y~xKkJwi#imh|v0(t*lQ;0rNed4a?>{DG)Pb7w5=_9a) zOj$>P-Kpz4`~<|sRAL-2Gm5WO1J>pP(J1v5t34Kmq2$j?jvpsfBDL;I@PCC@?5-$I zJkv`5xyPO=*iX57c&%m~Zg9ClU5H@81CEhuT}4S<7`h{N6Ir57inQB0wWhh&akMvg zM^y?gG4?j7@4xHo6q~4)(1Ay$X-t&h8o<>#BKr?pOQryAE}raMZX(kCOGAw5AiSDq zu>Hs6#HwrqaQi*DG{|XaSN%mt$KWwB6HQD{-(B+FO460W3~9t0DROc1EG3RbPsz(4pb55-vN{2V4TKsj;dm;6 z$5iK`-(jBF|IA=L^~$fSMJ&-&zqvCcj+~04PSRB{Wk0v&2O&|G!y8}jj`=nBuNMy1 zMZ0kcDD^=aG{&VUY{n95dGxWZT=5}CTt5J!)Q65f%RUIJsgba&n_C;T%4Y&O9bIP; zm!H3`yfX5e-7bF%r(qKHI*D z{cu5a7!;b^d2xy2mv{gN{H_M4a+^RnW1>I+*8WG4R#)?*2`hKn)3^5PF=sQ1{ff4d zTKEi;^!Kw6qS#*pyyi4*IGNb72xhD^^0?wP7v|@2?n9s-caD_}1thk(w32ImX5wv8 zv*cziZyYDoL|M0BiiwtB*in8Yp~8LJUnlp_>bn6uS9=lG@D^xdPO&;KQVPU{mXtWl zaD?;!XENZ4Vit)Hj$e1;y#Jhlj>>DhduP7;<-j~%|F4LE#{RM!>1<0*(_{V~Bz<@6 z?6$V1<#Qzu`5w9x@83Xhjgqx{&mh$SY4~^H>9JkM2&kvGuJUA>-FQe%v_}g#Z7a(; zQumi@4~}^5alpqz;P`&5pIZxRf+_~K#>+zSI}P&dWLR(4t`Q-uyADIzAGpyzg|- zzgzLmk`A7pojn(9+R>gt0+m`L69`h0Sun*9F(l5|bK}D;roFaCHY0ALNeLq@|%LH(mf^5dTne zs#{b%y9}**0&z<=O?*(~Y;xy`j>$b~x9rp(J&k4W$ee=s#V4W*l{z{4Ea6i>`wBdt zuD{o1;|ViYGbeAl=@ags5sK?^@z$q@I28sLek35d(Zm3nPPRx262>p~*^gKWLzH<= zYYMUwXT$D?{|ZN@vjk1G%fpB9jAW$2d>L-CkH{)Vem{B#yPvie#o}8d;U5nnJ|{s1 zgRHESn@W-`%r%;BmA0_$S5X7q(lj`^&HUqsWNb>5;ckP>CV_-Yh@W0lYrfas|C}rj7%a9Y~!+Sos@YGS-$4Xj+Q~sg!!6+#Mgn+cSw}1I! zxMv#d6&;Trz)K6!kmtXjGDHRW^C?@yC_TnTj#TX*pT@VD|CxW|j@#iYLr`!n3|10G zR;6HUq4yW6pWoiQ|30ejX-sG)0x{%;BM|vP{~}lj^9?rt4Bm|sS9hC~cprT0kw+O6k$4NLhcF3&F96`$k>7Cdl?<9-r6$DOaN}B?SK8wwYz4Fg z3i_~AOc!{(e6{S?y^#`DDC{96^K|Eq3p*%G{#hlxQQ)k&xbgt~wX+DUbK<;wbgE3Q zdul4DY3wzU$Q}@Fdx0D!>GYcM!xf9FY<(M1Rt)spdy{v$cZaKV7JEY)VRP-X1sY)e*TLnsLar`?^d?I&UrH5Qk+^oeP1QpU-@d(v z^Z)4@t=u}|elzqM|GQIR%+0h^w@$QDu#g_w0qBCTMW0BGAmJ)@f9Xv8O<`w2ZOanB0L|FPJMIg~dw3ggD=0dZl z*&;E75_N&2+z)fRA zAe6|Hp5zc$JgiN8+j}W1ogyIBF7wzhom5~u+rJ`~*itMxHkD{w) zQ~cg*pku1_&DLBwlT}w9R_$zJ97K8f(r_dn)r!Ni;NSMlp&5@1bs!lE^>2p;Fh0>4 zeh-nU<(}4RlR}^L@!9B2_S@x5;v~o7HA{{B%)xRS`y`-48IUAl_?eW{-&|^j{uFR3 znkk&YtojZGePMBNryuqEs)x)zIl+-h+-`!`6}F!GyBE^$9zPy{W)rHpTc=9oANYAP zaYo(p@JNY}WN~h+TK~!$1D;?5eg7{i)QjzT{AZ}Nz^%D7w{WPxc+*oZP3JzMezgR! z&>k8F)Ci&8OGiwfK~fBX2_{=G&401}0K}(;zQi&)URx%8nKUFuU=lN>sa>QGFi!iF z*i*}4VadZ8G~+%Xr@l_^HcN$r0d7@m#r%`-HI6wL2{JuRPe+lzv+XA9J45^rxsD0kVG;|#$1`Zt;8eaaGuQ@t*zt}vUyx7zQ-W=n* zD1V|+n&e1f%7hLW#NX#MLMpy?`_P1W@sKSE(OVmR-h1w1NpMWc%E>+VTwGpe;LtAU zQ_^Yqg7#1&bb_~aA&iYhf$I0=f}uWJ15|(;S%6=fn3Oa+H95WpecV}w`u#U+2}mq> z4zO70+>!L6+OhW5y~7&W3cnyE;+;F9G1l(wr*dY(l*x|TVS}A3zL!qcKZj%r8=IY( zbpo59A^ZDM>~4?pLxua<*ow@>I0>X~y!est7kkSU7)a=tlkA2qBx6UTvXUnmpK=RF zVU7uvoBn`nL6N=?vG0Ha7sAW(9%YP?ykcd}>zySr_b>m^rSd=_@i8Vyd}IenZ#KTW zTF}R3GG+y)on#9CY(%00@;)3K6l}?ct#!pD4fv9Mk_fm}UebwleeM?Cci_XcZjqr1 zk8k(1vV&fcAu=fB8QKRuFzI|sEj2L35s*oE7l%jzjHzyQGNF&x6q&H<}?%52e>D1Lg$DXkHQt;CJ? zufiB3I)C1b7V>#>3ZIK_VGKDMm##0n;dIB|>k9V)@i$8{X2Ih0nGXwiVy(T#vI{lwt>vQ0u?1EaVdW>A^>RuTx}moF;is z8`6!yIgDdMRZ3!_PmqVQ&;-xsVmk|?64fR?^jT*}CWfo-O+#e`g3`^KfuEWDj$C2= zcu%)5)cZhd@NA@WCwBMKA2{@1Dsm;APpoV+Hs_J9(f}RRpYTN0uQt&=vELY^vnoM7 zv>owaB5&5xTp>ygo^^J|R^uvCM^ciK#(v%`B;n=G3D%-|do#F6QJve4Lqs5tpH-pI z|Ld?39~7esqGMmU8U4c8R=aBw=@h;nEl=`*0K!*miDyEUiK}wOh;8-) zE4I428f^<8Aw0<@*PdR#dbNr6Twu3@l&q}WZ5Np_U8AfgH$?q|!C+ESzay3eS45~K zTYM{E1K++u3E9&0z}@}*CVnbja?sAT(~9vg4{Z*TIR*xB)FeWlP2--CfQ8{v zb;;{bI{2vKCw&nZmLV#s1^?;THFrN@oi_RrL7Ze^7|!Rl`7DJtT3e=LTYd zpcZhDva76?OTX{!o&I+k5TxRMn4H$#3R`_Tg&*K@&nDNpGT^Bv5_fEp!RyRZC!&sT z3&^@2d)HwpHsu7}%e{`+BLvMPpBlr1wa~siIUl&>29;_=>LJdU+XVCnx9Iv&JxT-V zy@xc=)p+*bvYTv<{w(zNx5~!6l~^k4yce%;U-n+!v-3b>-V%Ob#$|)E)Z(6_{+~t@ z+;~zvgr6>4xKP#B{#Wr9*>gPWSj&lii~Z527U=6s8Cv)eUW>JPcZ@#3DcEiQvT0<-DT@_7Hm2U%#H+{QLVmZb@v-QEaB(5#^L?F2IFGMRc(~2e-N? zS)1BhTJ~{!e{H1*v<27d^ZU;eo;w@USVB*)Jp5Zd!V#$kFClVJLYA?@)|;U`VN~4D z7~1S0Ww`U^1SglX^*tt7O^ zZ*GrSf5om9lcnE{))M-`B}l=AMqTGBs4&ZQN$%3wf?Lr z`<<&&E2k#P&Fn)~d%GLCT__ccI*X&hy7>MEvq8j2!>F!k1f=#=Dd9KCd_8B z(rd0#s?>X};QC1thk4|VYGw5RIQW157`REO{Cl#1sSbAj3R=!pg;y%VF)Sl*-_S?B z!+-}Dj@U)SJ=?~wqLk_A2xmHhZGmx-rs67oE6WrpT40(DUbv9)Rw>;%3GrSN+5woC zi}SREWTn52$V;7TJrUe=qE+jCOke!``mdM`9NRkjT%E~5`@wv~>xNhWum2Tf=#rt+wz2|kmKgc)MU;f7g(zM$-gM1FhcUs+C6 z@Yeysx~RO+@OR2p#>4IM<;yryRJpu^rUC=szzTYSG)GAz3AH@79&fBK7j;Le61NiZ zDRHQTlu|yj`hKk>RVl$fy%lcQ>%h2X{90TTy^|!)wq`{fwvo2a}JNBqiz%DgLjCulJPg z6TB(GR-Ol<8B$*JdUAAciB2um2kwfqJ+%bu<=)1k+%xMB8pQs7U6W{(akFxHE&7`X z1OEp74Ww@jT|9AzuSb8F1C@`9GhO`%uMwHtIlt>5oVDWD|7tF}FE|z(=eI_Up3!VZ zpCM+if#)0rF-$R+9_b6h{^?3)F5u@ex=L~YR%kGf<4A|3O=^8z2*tNycm^Z8x_TIX z�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%0DiD{N8hZW5%{|LR>HhxvuSX@PczGcp%qkl}2l4 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 z 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%0Ak>E6X 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 Date: Sat, 2 Aug 2025 11:14:45 -0700 Subject: [PATCH 27/54] upgrade deps (ruby) --- .ruby-version | 2 +- Gemfile | 2 +- Gemfile.lock | 31 ++++++++++++----------- docker_configs/api.Dockerfile | 2 +- frontend/__test_support__/setup_enzyme.ts | 3 ++- package.json | 26 +++++++++---------- 6 files changed, 34 insertions(+), 32 deletions(-) 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..fb8cd29f13 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" diff --git a/Gemfile.lock b/Gemfile.lock index 87171928d7..c862e31ccc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -120,7 +120,7 @@ GEM railties (>= 6.1.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.2) + faraday (2.13.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -140,7 +140,7 @@ GEM retriable (>= 2.0, < 4.a) google-apis-iamcredentials_v1 (0.24.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.54.0) + google-apis-storage_v1 (0.55.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -173,7 +173,7 @@ GEM 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.2) base64 @@ -207,7 +207,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) - multi_json (1.16.0) + multi_json (1.17.0) mutations (0.9.1) activesupport mutex_m (0.3.0) @@ -223,18 +223,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.2) + 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.0-aarch64-linux) + pg (1.6.0-x86_64-linux) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -343,10 +344,10 @@ GEM docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-cobertura (2.1.0) + simplecov-cobertura (3.0.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 +360,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) @@ -438,7 +439,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.4p34 + ruby 3.4.5p51 BUNDLED WITH - 2.6.9 + 2.7.1 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__/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/package.json b/package.json index 71daaaa4f1..b232df7391 100644 --- a/package.json +++ b/package.json @@ -37,26 +37,26 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "6.0.0", - "@blueprintjs/select": "6.0.0", + "@blueprintjs/core": "6.1.0", + "@blueprintjs/select": "6.0.1", "@monaco-editor/react": "4.7.0", "@parcel/transformer-sass": "2.15.4", "@parcel/transformer-typescript-tsc": "2.15.4", "@react-spring/three": "10.0.1", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", - "@rollbar/react": "0.12.1", + "@rollbar/react": "1.0.0", "@types/lodash": "4.17.20", "@types/markdown-it": "14.1.2", - "@types/node": "24.0.13", + "@types/node": "24.1.0", "@types/promise-timeout": "1.3.3", - "@types/react": "19.1.8", + "@types/react": "19.1.9", "@types/react-color": "3.0.13", - "@types/react-dom": "19.1.6", + "@types/react-dom": "19.1.7", "@types/three": "0.178.1", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", - "axios": "1.10.0", + "axios": "1.11.0", "bowser": "2.11.0", "browser-speech": "1.1.1", "delaunator": "5.0.1", @@ -70,8 +70,8 @@ "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.52.2", - "mqtt": "5.13.2", - "npm": "11.4.2", + "mqtt": "5.14.0", + "npm": "11.5.2", "parcel": "2.15.4", "process": "0.11.10", "promise-timeout": "1.3.0", @@ -81,7 +81,7 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.6.3", + "react-router": "7.7.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", @@ -89,13 +89,13 @@ "suncalc": "1.9.0", "takeme": "0.12.0", "three": "0.178.0", - "typescript": "5.8.3", + "typescript": "5.9.2", "url": "0.11.4" }, "devDependencies": { "@react-three/eslint-plugin": "0.1.2", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.3", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/delaunator": "5.0.3", From 1d6f702b48aa9a36166791bfb654556f21f104da Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 2 Aug 2025 15:33:28 -0700 Subject: [PATCH 28/54] add default axis order setting --- ...3_add_default_axis_order_to_fbos_config.rb | 9 +++++ db/structure.sql | 6 ++- .../__test_support__/fake_state/resources.ts | 3 +- frontend/constants.ts | 4 ++ .../__tests__/calculate_move_test.ts | 19 ++++++++- .../__tests__/{subs_test.ts => stubs_test.ts} | 26 ++++++++++++- frontend/demo/lua_runner/calculate_move.ts | 11 +++++- frontend/demo/lua_runner/stubs.ts | 19 +++++++++ .../__tests__/location_info_test.tsx | 11 +++++- .../farm_designer/__tests__/move_to_test.tsx | 3 +- frontend/farm_designer/location_info.tsx | 6 +++ frontend/farm_designer/move_to.tsx | 6 ++- .../__tests__/axis_order_test.tsx | 23 +++++++++-- .../__tests__/component_test.tsx | 15 +++++++ .../tile_computed_move/axis_order.tsx | 20 +++++++--- .../tile_computed_move/component.tsx | 9 ++++- .../tile_computed_move/interfaces.ts | 1 + frontend/settings/__tests__/index_test.tsx | 6 ++- .../__tests__/default_axis_order_test.tsx | 26 +++++++++++++ .../fbos_settings/default_axis_order.tsx | 39 +++++++++++++++++++ .../settings/fbos_settings/default_values.ts | 5 ++- frontend/settings/fbos_settings/interfaces.ts | 5 +++ .../__tests__/axis_settings_test.tsx | 4 +- .../__tests__/default_values_test.ts | 2 + .../hardware_settings/axis_settings.tsx | 4 ++ frontend/settings/maybe_highlight.tsx | 1 + package.json | 2 +- 27 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 db/migrate/20250802174543_add_default_axis_order_to_fbos_config.rb rename frontend/demo/lua_runner/__tests__/{subs_test.ts => stubs_test.ts} (67%) create mode 100644 frontend/settings/fbos_settings/__tests__/default_axis_order_test.tsx create mode 100644 frontend/settings/fbos_settings/default_axis_order.tsx 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 037175f7b7..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 ); @@ -3985,6 +3986,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20250221191831'), ('20250502201109'), ('20250514203443'), -('20250722234106'); +('20250722234106'), +('20250802174543'); diff --git a/frontend/__test_support__/fake_state/resources.ts b/frontend/__test_support__/fake_state/resources.ts index 286f519416..1f80dad22c 100644 --- a/frontend/__test_support__/fake_state/resources.ts +++ b/frontend/__test_support__/fake_state/resources.ts @@ -313,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/constants.ts b/frontend/constants.ts index 333694c7df..050e65967c 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -176,6 +176,9 @@ export namespace ToolTips { manually measure the height at various locations in the garden and add corresponding soil height points from the points panel. (default: 0)`); + export const DEFAULT_AXIS_ORDER = + trim(`Default axis order for movement. (default: X and Y together)`); + // Hardware Settings: Motors export const MAX_SPEED = trim(`Maximum travel speed after acceleration in millimeters per second. @@ -2084,6 +2087,7 @@ export enum DeviceSetting { gantryHeight = `Gantry Height`, safeHeight = `Safe Height`, fallbackSoilHeight = `Fallback Soil Height`, + defaultAxisOrder = `Default Axis Order`, // Motors motors = `Motors`, diff --git a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts index 193e793cb6..c62aab0cb5 100644 --- a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts +++ b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts @@ -32,9 +32,26 @@ jest.mock("../../../three_d_garden/triangle_functions", () => ({ import { AxisAddition, AxisOverwrite, Move, MoveBodyItem, ParameterApplication, } from "farmbot"; -import { calculateMove } from "../calculate_move"; +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 }); diff --git a/frontend/demo/lua_runner/__tests__/subs_test.ts b/frontend/demo/lua_runner/__tests__/stubs_test.ts similarity index 67% rename from frontend/demo/lua_runner/__tests__/subs_test.ts rename to frontend/demo/lua_runner/__tests__/stubs_test.ts index 1d884f2c6e..f3b33fd427 100644 --- a/frontend/demo/lua_runner/__tests__/subs_test.ts +++ b/frontend/demo/lua_runner/__tests__/stubs_test.ts @@ -1,3 +1,4 @@ +import { TaggedFbosConfig } from "farmbot"; import { fakeFbosConfig, fakeFirmwareConfig, @@ -6,14 +7,14 @@ import { let mockFirmwareConfig = fakeFirmwareConfig(); let mockWebAppConfig = fakeWebAppConfig(); -let mockFbosConfig = fakeFbosConfig(); +let mockFbosConfig: TaggedFbosConfig | undefined = fakeFbosConfig(); jest.mock("../../../resources/getters", () => ({ getFirmwareConfig: () => mockFirmwareConfig, getWebAppConfig: () => mockWebAppConfig, getFbosConfig: () => mockFbosConfig, })); -import { getGardenSize, getSafeZ } from "../stubs"; +import { getDefaultAxisOrder, getGardenSize, getSafeZ } from "../stubs"; describe("getGardenSize()", () => { it("gets garden size: axis lengths", () => { @@ -52,3 +53,24 @@ describe("getSafeZ()", () => { 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/calculate_move.ts b/frontend/demo/lua_runner/calculate_move.ts index de14640ff3..347ae936e2 100644 --- a/frontend/demo/lua_runner/calculate_move.ts +++ b/frontend/demo/lua_runner/calculate_move.ts @@ -12,9 +12,16 @@ import { maybeFindPointById, maybeFindSlotByToolId, } from "../../resources/selectors"; -import { getSafeZ, getSoilHeight } from "./stubs"; +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, @@ -22,7 +29,7 @@ export const calculateMove = ( ): { moves: XyzNumber[], warnings: string[] } => { const pos = clone(current); const warnings: string[] = []; - const moveBodyItems = body || []; + const moveBodyItems = addDefaults(body || []); // eslint-disable-next-line complexity moveBodyItems.map(item => { switch (item.kind) { diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index 6ec739d75f..f032a6eed4 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -1,5 +1,9 @@ import { store } from "../../redux/store"; import { + ALLOWED_GROUPING, + ALLOWED_ROUTE, + AxisOrder, + SafeZ, TaggedFbosConfig, TaggedFirmwareConfig, TaggedWebAppConfig, } from "farmbot"; import { calculateAxialLengths } from "../../controls/move/direction_axes_props"; @@ -66,3 +70,18 @@ export const getGroupPoints = (resources: ResourceIndex, groupId: number) => { 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 } }]; + } +}; 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 90b2c11ffe..2d54c4e8dd 100644 --- a/frontend/farm_designer/__tests__/move_to_test.tsx +++ b/frontend/farm_designer/__tests__/move_to_test.tsx @@ -36,6 +36,7 @@ describe("", () => { botOnline: true, locked: false, dispatch: jest.fn(), + defaultAxisOrder: "safe_z", }); it("moves to location: custom z value", () => { @@ -64,7 +65,7 @@ describe("", () => { it("changes safe z value", () => { render(); expect(screen.queryByText("Safe Z")).not.toBeInTheDocument(); - const dropdown = screen.getByRole("button", { name: "All at once" }); + 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" }); diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index bcdb99b7ae..f577b9ecb1 100644 --- a/frontend/farm_designer/location_info.tsx +++ b/frontend/farm_designer/location_info.tsx @@ -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
this.setState({ speed })} /> ", () => { [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, "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) => { @@ -52,6 +52,16 @@ describe("", () => { }); }); + 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(); @@ -59,6 +69,8 @@ describe("", () => { 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(); }); }); @@ -76,7 +88,9 @@ describe("getAxisGroupingState()", () => { const move: Move = { kind: "move", args: {}, - body: [{ kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }], + body: [ + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, + ], }; expect(getAxisGroupingState(move)).toEqual("z,y,x"); }); @@ -91,13 +105,14 @@ describe("getAxisGroupingState()", () => { }); }); - 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" } }], + body: [ + { kind: "axis_order", args: { grouping: "z,y,x", route: "in_order" } }, + ], }; expect(getAxisRouteState(move)).toEqual("in_order"); }); 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 51756046bc..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 => { @@ -165,6 +170,16 @@ describe("", () => { 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", () => { const p = fakeProps(); const wrapper = shallow(); diff --git a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx index da94234fc5..6e58b20bac 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/axis_order.tsx @@ -14,21 +14,28 @@ export const axisOrder = ( ): AxisOrder[] => !(grouping && route) ? [] : [{ kind: "axis_order", args: { grouping, route } }]; -export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => - +export const AxisOrderInputRow = (props: AxisOrderInputRowProps) => { + const defaultLabel = props.defaultValue + ? ` (${DDI_LOOKUP()[props.defaultValue].label})` + : ""; + return
; +}; + +export const getAxisOrderOptions = () => + DevSettings.allOrderOptionsEnabled() ? ALL_DDIS() : DDIS(); -const getSelectedItem = ( +export const getSelectedAxisOrder = ( safeZ: boolean, grouping: AxisGrouping, route: AxisRoute, @@ -45,6 +52,7 @@ 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 => { diff --git a/frontend/sequences/step_tiles/tile_computed_move/component.tsx b/frontend/sequences/step_tiles/tile_computed_move/component.tsx index ca73cd8068..85912630c4 100644 --- a/frontend/sequences/step_tiles/tile_computed_move/component.tsx +++ b/frontend/sequences/step_tiles/tile_computed_move/component.tsx @@ -33,6 +33,7 @@ import { getNewAxisOrderState, } from "./axis_order"; import { StepParams } from "../../interfaces"; +import { getFbosConfig } from "../../../resources/getters"; /** * _Computed move_ @@ -313,16 +314,20 @@ export class ComputedMove setAxisState={this.setAxisState} /> : undefined; - AxisOrderInputRow = () => - ((this.state.axisGrouping && this.state.axisRoute) + 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 ", () => { 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/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..86ad92f9fb --- /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/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/package.json b/package.json index b232df7391..b55bf8129b 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", - "farmbot": "15.9.1", + "farmbot": "15.9.2", "fengari": "0.1.4", "fengari-web": "0.1.4", "i18next": "25.3.2", From f2f8d903fded5f652efcefa662d42d7b1bbd76ee Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Sat, 2 Aug 2025 16:54:16 -0700 Subject: [PATCH 29/54] eslint-ignore problem file --- .eslintignore | 1 + frontend/wizard/step.tsx | 1 + 2 files changed, 2 insertions(+) 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/frontend/wizard/step.tsx b/frontend/wizard/step.tsx index 26b635dd59..5069246e79 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"; From 92bfa11daecc9dba37bd83df85a5725acf1bb8b1 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 12 Aug 2025 16:05:15 -0700 Subject: [PATCH 30/54] use correct tooltip --- frontend/settings/fbos_settings/default_axis_order.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/settings/fbos_settings/default_axis_order.tsx b/frontend/settings/fbos_settings/default_axis_order.tsx index 86ad92f9fb..add638621a 100644 --- a/frontend/settings/fbos_settings/default_axis_order.tsx +++ b/frontend/settings/fbos_settings/default_axis_order.tsx @@ -25,7 +25,7 @@ export const DefaultAxisOrder = (props: DefaultAxisOrderProps) => { - +
Date: Wed, 13 Aug 2025 11:02:18 -0700 Subject: [PATCH 31/54] add 3D sequence visualization and fix demo trough movements --- .../__tests__/calculate_move_test.ts | 3 +- frontend/demo/lua_runner/actions.ts | 4 + frontend/demo/lua_runner/calculate_move.ts | 10 ++- .../sequences/__tests__/all_steps_test.tsx | 4 +- .../sequence_editor_middle_active_test.tsx | 15 +++- .../__tests__/sequence_editor_middle_test.tsx | 1 + .../sequences/__tests__/sequences_test.tsx | 1 + frontend/sequences/all_steps.tsx | 2 +- frontend/sequences/interfaces.ts | 2 +- .../sequences/panel/__tests__/editor_test.tsx | 1 + .../sequences/panel/__tests__/list_test.tsx | 1 + frontend/sequences/sequence_editor_middle.tsx | 1 + .../sequence_editor_middle_active.tsx | 3 + frontend/sequences/sequences.tsx | 1 + frontend/sequences/state_to_props.ts | 2 +- .../__tests__/visualization_test.tsx | 78 +++++++++++++++++++ frontend/three_d_garden/garden_model.tsx | 4 + frontend/three_d_garden/visualization.tsx | 46 +++++++++++ 18 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/visualization_test.tsx create mode 100644 frontend/three_d_garden/visualization.tsx diff --git a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts index c62aab0cb5..a7020bc92a 100644 --- a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts +++ b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts @@ -243,6 +243,7 @@ describe("calculateMove()", () => { 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", @@ -258,7 +259,7 @@ describe("calculateMove()", () => { ], }; expect(calculateMove(command.body, { x: 0, y: 0, z: 0 }, [])) - .toEqual({ moves: [{ x: 1, y: 2, z: 3 }], warnings: [] }); + .toEqual({ moves: [{ x: 0, y: 2, z: 3 }], warnings: [] }); }); it("handles missing tool", () => { diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index 825a736073..4cdadf54e7 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -90,6 +90,7 @@ export const setCurrent = (position: XyzNumber) => { export const expandActions = ( actions: Action[], variables: ParameterApplication[] | undefined, + stashedCurrentPosition?: XyzNumber, ): Action[] => { const expanded: Action[] = []; const timeStepMs = parseInt(localStorage.getItem("timeStepMs") || "250"); @@ -239,6 +240,9 @@ export const expandActions = ( break; } }); + if (stashedCurrentPosition) { + setCurrent(stashedCurrentPosition); + } return expanded; }; diff --git a/frontend/demo/lua_runner/calculate_move.ts b/frontend/demo/lua_runner/calculate_move.ts index 347ae936e2..a92a79e945 100644 --- a/frontend/demo/lua_runner/calculate_move.ts +++ b/frontend/demo/lua_runner/calculate_move.ts @@ -86,12 +86,14 @@ export const calculateMove = ( if (!toolSlot) { break; } + const toolSlotBody = clone(toolSlot.body); + if (toolSlotBody.gantry_mounted) { toolSlotBody.x = pos.x; } if (item.args.axis == "all") { - pos.x = toolSlot.body.x; - pos.y = toolSlot.body.y; - pos.z = toolSlot.body.z; + pos.x = toolSlotBody.x; + pos.y = toolSlotBody.y; + pos.z = toolSlotBody.z; } else { - pos[item.args.axis] = toolSlot.body[item.args.axis]; + pos[item.args.axis] = toolSlotBody[item.args.axis]; } break; case "identifier": 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 72e5c83007..3289adedce 100644 --- a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx +++ b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx @@ -65,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, @@ -120,6 +121,7 @@ describe("", () => { getWebAppConfigValue: jest.fn(), sequencesState: emptyState().consumers.sequences, showName: true, + visualized: undefined, }; }; @@ -313,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({ @@ -322,6 +324,16 @@ 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(); @@ -587,6 +599,7 @@ describe("", () => { toggleViewSequenceCeleryScript: jest.fn(), sequencesState: emptyState().consumers.sequences, viewCeleryScript: true, + visualized: undefined, }); it("edits color", () => { 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/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/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..e0e667cfe8 100644 --- a/frontend/sequences/sequence_editor_middle_active.tsx +++ b/frontend/sequences/sequence_editor_middle_active.tsx @@ -265,6 +265,9 @@ 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; diff --git a/frontend/sequences/sequences.tsx b/frontend/sequences/sequences.tsx index 0df850eae7..7b000ebeb3 100644 --- a/frontend/sequences/sequences.tsx +++ b/frontend/sequences/sequences.tsx @@ -66,6 +66,7 @@ export class RawSequences extends React.Component { hardwareFlags={this.props.hardwareFlags} farmwareData={this.props.farmwareData} getWebAppConfigValue={this.props.getWebAppConfigValue} + visualized={undefined} sequencesState={sequencesState} />
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/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/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index c2d027ed03..f576510781 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -33,6 +33,7 @@ import { isMobile } from "../screen_size"; import { computeSurface } from "./triangles"; import { BigDistance } from "./constants"; import { precomputeTriangles, getZFunc } from "./triangle_functions"; +import { Visualization } from "./visualization"; const AnimatedGroup = animated(Group); @@ -215,6 +216,9 @@ export const GardenModel = (props: GardenModelProps) => { getZ={getZ} dispatch={dispatch} />)} + 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 && + ; +}; From 001ad268feaf91805bb2997e0559c9fb463c6d32 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 13 Aug 2025 11:30:18 -0700 Subject: [PATCH 32/54] handle JSON errors --- app/mutations/auth/create_token_from_credentials.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 20723fde2eb6041c7b51a0acc204131fd4af140d Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 14 Aug 2025 17:45:59 -0700 Subject: [PATCH 33/54] sequence editor styling improvements --- frontend/css/global/buttons.scss | 4 - frontend/css/global/global.scss | 14 ++- frontend/css/panels/sequences.scss | 87 +++++++++--------- .../sequence_editor_middle_active_test.tsx | 4 +- frontend/sequences/panel/editor.tsx | 5 +- .../sequence_editor_middle_active.tsx | 91 +++++++++---------- frontend/sequences/step_button_cluster.tsx | 2 +- 7 files changed, 99 insertions(+), 108 deletions(-) diff --git a/frontend/css/global/buttons.scss b/frontend/css/global/buttons.scss index 940f19a0db..aa31991207 100644 --- a/frontend/css/global/buttons.scss +++ b/frontend/css/global/buttons.scss @@ -295,10 +295,6 @@ justify-content: right; .fa-code { font-weight: bold; - color: $gray; - &.enabled { - color: $dark_gray; - } } .inactive { color: var(--secondary-bg); diff --git a/frontend/css/global/global.scss b/frontend/css/global/global.scss index 61c955cf38..47b623b233 100644 --- a/frontend/css/global/global.scss +++ b/frontend/css/global/global.scss @@ -119,19 +119,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; } } diff --git a/frontend/css/panels/sequences.scss b/frontend/css/panels/sequences.scss index 729b49d137..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) { @@ -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,7 +185,7 @@ text-align: center; cursor: pointer; &:hover { - background: $light_gray; + background: var(--secondary-bg); } label { cursor: pointer; @@ -234,13 +237,28 @@ } } &.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; @@ -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 { @@ -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; } } @@ -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; @@ -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; - } .bp6-popover-wrapper { - display: inline-block; - margin-left: 1rem; - } - p { margin-left: 1rem; } } -.import-banner { - margin-bottom: 1rem; -} - .imported-banner { background: color.adjust($blue, $alpha: -0.4); } diff --git a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx index 3289adedce..09afdef1ca 100644 --- a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx +++ b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx @@ -339,7 +339,7 @@ describe("", () => { 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); }); @@ -348,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); }); 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/sequence_editor_middle_active.tsx b/frontend/sequences/sequence_editor_middle_active.tsx index e0e667cfe8..3789273149 100644 --- a/frontend/sequences/sequence_editor_middle_active.tsx +++ b/frontend/sequences/sequence_editor_middle_active.tsx @@ -262,7 +262,6 @@ export const SequenceBtnGroup = ({ sequencesState, getWebAppConfigValue, toggleViewSequenceCeleryScript, - viewCeleryScript, visualized, }: SequenceBtnGroupProps) => { if (visualized && sequence.uuid != visualized) { @@ -272,7 +271,7 @@ export const SequenceBtnGroup = ({ 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))))} />
; }; @@ -545,12 +539,13 @@ export class SequenceEditorMiddleActive extends showName={this.props.showName} />} {view == "local" ?
- + {!viewSequenceCeleryScript && + } {!viewSequenceCeleryScript && { {props.showName &&

{props.sequencePreview?.body.name}

} {props.viewCeleryScript && }
)} - + {props.dispatch != undefined + && props.botOnline != undefined + && props.arduinoBusy != undefined + && props.defaultAxes != undefined + && props.movementState != undefined && + }
; }; diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx index 3912089451..c382e438d2 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,8 @@ import { SelectMapOrigin, SensorsCheck, SetHome, + SlotCoordinateRows, + SlotCoordinateRowsProps, SoilHeightMeasurementCheck, SwapJogButton, SwitchCameraCalibrationMethod, @@ -85,6 +88,7 @@ import { fakeAlert, fakeFarmwareEnv, fakeFarmwareInstallation, fakeFbosConfig, fakeFirmwareConfig, fakeImage, fakeLog, fakePinBinding, fakeTool, + fakeToolSlot, fakeUser, fakeWebAppConfig, } from "../../__test_support__/fake_state/resources"; @@ -94,7 +98,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 +788,25 @@ 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)); + }); +}); + 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..c0647691f7 100644 --- a/frontend/wizard/__tests__/step_test.tsx +++ b/frontend/wizard/__tests__/step_test.tsx @@ -8,7 +8,9 @@ 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 { + fakeToolSlot, fakeWizardStepResult, +} from "../../__test_support__/fake_state/resources"; const fakeWizardStep = (): WizardStep => ({ section: WizardSectionSlug.controls, @@ -193,6 +195,14 @@ 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 pin bindings", () => { const p = fakeProps(); p.step.pinBindingOptions = { editing: false }; diff --git a/frontend/wizard/checks.tsx b/frontend/wizard/checks.tsx index b32a1c01e1..f814943262 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"; @@ -103,6 +104,7 @@ 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"; export const Language = (props: WizardStepComponentProps) => { const user = getUserAccountSettings(props.resources); @@ -805,6 +807,35 @@ 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]; + const updateSlot = (update: Partial) => { + props.dispatch(edit(slot, update)); + props.dispatch(save(slot.uuid)); + }; + 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..1bc0359412 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: {}, + slotInputRows: 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..c48a099258 100644 --- a/frontend/wizard/interfaces.ts +++ b/frontend/wizard/interfaces.ts @@ -75,6 +75,7 @@ export interface WizardStep { componentOptions?: ComponentOptions; warning?: string; controlsCheckOptions?: ControlsCheckOptions; + slotInputRows?: number[]; pinBindingOptions?: PinBindingOptions; question: string; outcomes: WizardStepOutcome[]; @@ -98,7 +99,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 5069246e79..c15845bb5c 100644 --- a/frontend/wizard/step.tsx +++ b/frontend/wizard/step.tsx @@ -11,7 +11,7 @@ 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 } from "./checks"; import { SetupWizardContent } from "../constants"; import { ExternalUrl } from "../external_urls"; import { FilePath } from "../internal_urls"; @@ -24,7 +24,12 @@ export const WizardStepHeader = (props: WizardStepHeaderProps) => { const normalStepColor = stepDone ? "green" : "gray"; const stepColor = stepFail ? "red" : normalStepColor; - return
@@ -95,6 +100,12 @@ export const WizardStepContainer = (props: WizardStepContainerProps) => { } + {step.slotInputRows && + } {step.pinBindingOptions && { resources={props.resources} />}
; })} -

{t("Something else happened and I need additional help")}

{otherSelected &&

From cc15858effc1e18011dd7480de1e008734410663 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 Aug 2025 14:23:16 -0700 Subject: [PATCH 39/54] shorten water job name --- frontend/demo/lua_runner/lua.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/demo/lua_runner/lua.ts b/frontend/demo/lua_runner/lua.ts index 1c3c095bd2..507dd99313 100644 --- a/frontend/demo/lua_runner/lua.ts +++ b/frontend/demo/lua_runner/lua.ts @@ -707,7 +707,7 @@ end function water(plant, params) local plant_name_xy = plant.name .. " at (" .. plant.x .. ", " .. plant.y .. ")" - local job_name = "Watering " .. plant_name_xy + 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") @@ -736,7 +736,7 @@ function water(plant, params) -- Water the plant set_job(job_name, { status = "Watering", percent = 50 }) - send_message("info", "Watering " .. plant_age .. " day old " .. plant_name_xy .. " " .. water_ml .. "mL") + send_message("info", "Watering " .. plant_age .. " day old " .. plant.name .. " " .. water_ml .. "mL") dispense(water_ml, params) complete_job(job_name) end From e922aed45fbffbd1cd2edd5971879aac239692ae Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 Aug 2025 14:27:00 -0700 Subject: [PATCH 40/54] upgrade deps --- Gemfile | 1 + Gemfile.lock | 29 ++++++++++++++--------------- package.json | 24 ++++++++++++------------ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Gemfile b/Gemfile index fb8cd29f13..7bac44de02 100755 --- a/Gemfile +++ b/Gemfile @@ -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 c862e31ccc..a77a6773be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -113,7 +113,7 @@ GEM drb (2.2.3) e2mmap (0.1.0) erubi (1.13.1) - factory_bot (6.5.4) + factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.0) factory_bot (~> 6.5) @@ -130,14 +130,14 @@ GEM 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.55.0) @@ -149,10 +149,10 @@ 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) @@ -169,8 +169,6 @@ GEM 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.13.2) @@ -234,8 +232,8 @@ GEM rack (>= 1.6.13) rackup (>= 1.0.1) rake (>= 12.3.3) - pg (1.6.0-aarch64-linux) - pg (1.6.0-x86_64-linux) + pg (1.6.1-aarch64-linux) + pg (1.6.1-x86_64-linux) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -327,7 +325,7 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.4) + rspec-support (3.13.5) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) scenic (1.9.0) @@ -412,6 +410,7 @@ DEPENDENCIES logger lograge mutations + mutex_m ostruct passenger pg diff --git a/package.json b/package.json index b55bf8129b..bdc7685fa8 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "6.1.0", - "@blueprintjs/select": "6.0.1", + "@blueprintjs/core": "6.2.1", + "@blueprintjs/select": "6.0.3", "@monaco-editor/react": "4.7.0", "@parcel/transformer-sass": "2.15.4", "@parcel/transformer-typescript-tsc": "2.15.4", @@ -48,23 +48,23 @@ "@rollbar/react": "1.0.0", "@types/lodash": "4.17.20", "@types/markdown-it": "14.1.2", - "@types/node": "24.1.0", + "@types/node": "24.3.0", "@types/promise-timeout": "1.3.3", - "@types/react": "19.1.9", + "@types/react": "19.1.10", "@types/react-color": "3.0.13", "@types/react-dom": "19.1.7", - "@types/three": "0.178.1", + "@types/three": "0.179.0", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", "axios": "1.11.0", - "bowser": "2.11.0", + "bowser": "2.12.0", "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", "farmbot": "15.9.2", "fengari": "0.1.4", "fengari-web": "0.1.4", - "i18next": "25.3.2", + "i18next": "25.4.0", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", @@ -81,21 +81,21 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.7.1", + "react-router": "7.8.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", "rollbar": "2.26.4", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.178.0", + "three": "0.179.1", "typescript": "5.9.2", "url": "0.11.4" }, "devDependencies": { "@react-three/eslint-plugin": "0.1.2", "@testing-library/dom": "10.4.1", - "@testing-library/jest-dom": "6.6.4", + "@testing-library/jest-dom": "6.7.0", "@testing-library/react": "16.3.0", "@testing-library/user-event": "14.6.1", "@types/delaunator": "5.0.3", @@ -127,9 +127,9 @@ "raf": "3.4.1", "react-addons-test-utils": "15.6.2", "react-test-renderer": "18.3.1", - "sass": "1.89.2", + "sass": "1.90.0", "sass-lint": "1.13.1", - "ts-jest": "29.4.0", + "ts-jest": "29.4.1", "tslint": "5.20.1" } } From bd64d8e6637ba8b61297b9e1b91bc8b354de952b Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 20 Aug 2025 14:53:51 -0700 Subject: [PATCH 41/54] add max_auto_reruns to ci config --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) 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 From 739b70b282504fb5879a55c6087c492ab00a1f61 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 22 Aug 2025 01:47:15 -0700 Subject: [PATCH 42/54] handle ai error messages --- app/controllers/api/ais_controller.rb | 9 +++++++++ spec/controllers/api/ai/ai_controller_spec.rb | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) 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/spec/controllers/api/ai/ai_controller_spec.rb b/spec/controllers/api/ai/ai_controller_spec.rb index d82545801d..3eaebaec00 100644 --- a/spec/controllers/api/ai/ai_controller_spec.rb +++ b/spec/controllers/api/ai/ai_controller_spec.rb @@ -53,7 +53,7 @@ def chunk(content, done=nil) expect(response.body).to eq("red") end - it "handles errors" do + it "handles timeout" do sign_in user payload = { prompt: "write code", @@ -69,6 +69,23 @@ def chunk(content, done=nil) expect(response.status).to eq(422) end + it "handles error" do + sign_in user + payload = { + prompt: "write code", + context_key: "lua", + sequence_id: nil, + } + stub_request(:get, URL_PATTERN).to_return( + body: "{---\n---# section\ncontent```lua\n```}") + + stub_request(:post, "https://api.openai.com/v1/chat/completions").to_return( + body: "{\"error\":\"Invalid request\"}") + + post :create, body: payload.to_json + expect(response.status).to eq(422) + end + it "handles connection issues" do sign_in user payload = { From cbc9f96266499bc242959b86bd9e4bcafa3d3bed Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 22 Aug 2025 01:47:53 -0700 Subject: [PATCH 43/54] increase spinach demo water amount --- app/mutations/devices/seeders/demo_account_seeder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mutations/devices/seeders/demo_account_seeder.rb b/app/mutations/devices/seeders/demo_account_seeder.rb index 363b42407f..332503ca4e 100644 --- a/app/mutations/devices/seeders/demo_account_seeder.rb +++ b/app/mutations/devices/seeders/demo_account_seeder.rb @@ -32,7 +32,7 @@ def add_curves device: device, name: "Spinach water curve", type: "water", - data: { 1 => 50, 30 => 130, 40 => 130, 45 => 100, 60 => 100 }, + data: { 1 => 200, 30 => 500, 40 => 500, 45 => 300, 60 => 300 }, ) Curves::Create.run!( device: device, From f0b7e8bf41b5e756be373664762c67a7f86121fa Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 22 Aug 2025 02:06:52 -0700 Subject: [PATCH 44/54] show v1.8 seed option during setup --- frontend/messages/__tests__/cards_test.tsx | 2 +- frontend/messages/cards.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/frontend/messages/__tests__/cards_test.tsx b/frontend/messages/__tests__/cards_test.tsx index c9decf9e24..a54b26e2f2 100644 --- a/frontend/messages/__tests__/cards_test.tsx +++ b/frontend/messages/__tests__/cards_test.tsx @@ -276,7 +276,7 @@ describe("changeFirmwareHardware()", () => { 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..b5481c7883 100644 --- a/frontend/messages/cards.tsx +++ b/frontend/messages/cards.tsx @@ -257,12 +257,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" }, From aa96c7c720b7e6be8b1dace5334c4f11b8bfb548 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 22 Aug 2025 20:27:58 -0700 Subject: [PATCH 45/54] better align wizard slot components --- frontend/css/panels/setup_wizard.scss | 3 +++ frontend/wizard/checks.tsx | 4 ++-- frontend/wizard/step.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/css/panels/setup_wizard.scss b/frontend/css/panels/setup_wizard.scss index 8d6ca38c44..8148c54ec3 100644 --- a/frontend/css/panels/setup_wizard.scss +++ b/frontend/css/panels/setup_wizard.scss @@ -210,6 +210,9 @@ margin-top: 0; } } + .move-settings { + display: none; + } } .troubleshooting { a { diff --git a/frontend/wizard/checks.tsx b/frontend/wizard/checks.tsx index f814943262..fa47ca278b 100644 --- a/frontend/wizard/checks.tsx +++ b/frontend/wizard/checks.tsx @@ -817,14 +817,14 @@ export interface SlotCoordinateRowsProps { export const SlotCoordinateRows = (props: SlotCoordinateRowsProps) => { const locationData = validBotLocationData(props.bot.hardware.location_data); const slots = selectAllToolSlotPointers(props.resources); - return

+ return
{props.indexValues.map(index => { const slot = slots[index]; const updateSlot = (update: Partial) => { props.dispatch(edit(slot, update)); props.dispatch(save(slot.uuid)); }; - return
+ return
{ {step.video &&