From 7ce918e7a83d16fbb06324613763956bf9a67a4b Mon Sep 17 00:00:00 2001 From: Oliver Fuchs Date: Mon, 10 Jul 2023 17:02:12 -0700 Subject: [PATCH 1/3] Begin Autoplot gui --- src/main.py | 55 +++++++++++++++++++++++++++++++++++++++++-------- src/res/main.kv | 40 +++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/main.py b/src/main.py index ce174a4..cb20322 100644 --- a/src/main.py +++ b/src/main.py @@ -2,6 +2,7 @@ import argparse import asyncio import os +import datetime from typing import List from amiga_package import ops @@ -24,9 +25,11 @@ # kivy imports from kivy.app import App # noqa: E402 from kivy.lang.builder import Builder # noqa: E402 +from kivy.uix.dropdown import DropDown +from kivy.uix.button import Button -class TemplateApp(App): +class AutoPlot(App): """Base class for the main Kivy app.""" def __init__(self) -> None: @@ -43,6 +46,40 @@ def on_exit_btn(self) -> None: """Kills the running kivy application.""" App.get_running_app().stop() + def show_button(self,button): + print(button.id) + + def on_toggle_button_press(self, instance, directory): + print('Button pressed:', instance.text, directory) + try: + # Get the list of all files in the directory + file_list = os.listdir(directory) + + # Loop through each file + for filename in file_list: + # Create the full file path by joining the directory and the filename + filepath = os.path.join(directory, filename) + + # Check if it's a file, not a directory + if os.path.isfile(filepath): + # Get file info + file_info = os.stat(filepath) + + # Get file size + file_size = file_info.st_size + + # Get file modification time + modification_time = datetime.datetime.fromtimestamp(file_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S') + + # Print the information + print(f"File Name: {filename}, File Size: {file_size} bytes, Last Modified: {modification_time}") + except FileNotFoundError: + print(f"The directory '{directory}' does not exist.") + except PermissionError: + print(f"Permission denied for directory '{directory}'.") + + + async def app_func(self): async def run_wrapper() -> None: # we don't actually need to set asyncio as the lib because it is @@ -61,14 +98,14 @@ async def template_function(self) -> None: while self.root is None: await asyncio.sleep(0.01) - while True: - await asyncio.sleep(1.0) + # while True: + # await asyncio.sleep(1.0) - # increment the counter using internal libs and update the gui - self.counter = ops.add(self.counter, 1) - self.root.ids.counter_label.text = ( - f"{'Tic' if self.counter % 2 == 0 else 'Tac'}: {self.counter}" - ) + # # increment the counter using internal libs and update the gui + # self.counter = ops.add(self.counter, 1) + # self.root.ids.counter_label.text = ( + # f"{'Tic' if self.counter % 2 == 0 else 'Tac'}: {self.counter}" + # ) if __name__ == "__main__": @@ -80,7 +117,7 @@ async def template_function(self) -> None: loop = asyncio.get_event_loop() try: - loop.run_until_complete(TemplateApp().app_func()) + loop.run_until_complete(AutoPlot().app_func()) except asyncio.CancelledError: pass loop.close() diff --git a/src/res/main.kv b/src/res/main.kv index 943c72b..0003a27 100644 --- a/src/res/main.kv +++ b/src/res/main.kv @@ -10,7 +10,39 @@ RelativeLayout: source: "assets/back_button_normal.png" if self.parent.state == "normal" else "assets/back_button_down.png" pos: self.parent.pos size: self.parent.size - Label: - id: counter_label - text: "Tic: 0" - font_size: 40 + + BoxLayout: + size_hint_y: .1 + size_hint_x: .5 + pos_hint: {'top': .85} + padding: 5, 5 + spacing: '5dp' + ToggleButton: + group: 'toggle_group' + id: guide_line + text: 'Guidance Line' + font_size: '25' + allow_no_selection: False + on_state: if self.state == 'down': app.on_toggle_button_press(self,"/data/") + + ToggleButton: + group: 'toggle_group' + id: path + text: 'Path' + font_size: '25' + allow_no_selection: False + on_state: if self.state == 'down': app.on_toggle_button_press(self,"/data/farm_ng/paths") + + ToggleButton: + group: 'toggle_group' + id: field_bound + text: 'Field Boundry' + font_size: '25' + allow_no_selection: False + on_state: if self.state == 'down': app.on_toggle_button_press(self,"/data/") + + RecycleView: + id: recycleview + viewclass: 'Label' + + From 072a6877e1c2896ede1ba27a65a574b24238213c Mon Sep 17 00:00:00 2001 From: edgarriba Date: Fri, 14 Jul 2023 18:02:32 -0700 Subject: [PATCH 2/3] Work on save functionality of the boundary app --- main.py | 123 ++++++++++++++++++++++++++++++ main_screen.py | 129 ++++++++++++++++++++++++++++++++ map_view.py | 198 +++++++++++++++++++++++++++++++++++++++++++++++++ path_screen.py | 80 ++++++++++++++++++++ utils.py | 57 ++++++++++++++ 5 files changed, 587 insertions(+) create mode 100644 main.py create mode 100644 main_screen.py create mode 100644 map_view.py create mode 100644 path_screen.py create mode 100644 utils.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..32edc17 --- /dev/null +++ b/main.py @@ -0,0 +1,123 @@ +# Copyright (c) farm-ng, inc. Amiga Development Kit License, Version 0.1 +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path + +# global constants (in pixels) +FARM_NG_SCREEN_WIDTH_PX = 1280 +FARM_NG_SCREEN_HEIGHT_PX = 800 + +# global constants for data paths +# TODO: make this configurable or get from launch file +FARM_NG_DEFAULT_DATA_PATH = Path("/data/farm_ng") + +# Must come before kivy imports +os.environ["KIVY_NO_ARGS"] = "1" + +# gui configs must go before any other kivy import +from kivy.config import Config # noreorder # noqa: E402 + +Config.set("graphics", "resizable", False) +Config.set("graphics", "width", str(FARM_NG_SCREEN_WIDTH_PX)) +Config.set("graphics", "height", str(FARM_NG_SCREEN_HEIGHT_PX)) +Config.set("graphics", "fullscreen", "false") +Config.set("input", "mouse", "mouse,disable_on_activity") +Config.set("kivy", "keyboard_mode", "systemanddock") + +# kivy imports +from boundary_screen import BoundaryScreen +from farm_ng.gps_utils.coordinates import GpsCoordinates +from kivy.app import App # noqa: E402 +from kivy.uix.screenmanager import ScreenManager +from main_screen import MainScreen +from path_screen import PathScreen +from map_view import FullMapView + +class AutoPlot(App): + """The main application class.""" + + def __init__(self, data_path: Path) -> None: + super().__init__() + self._data_path = data_path + + self.async_tasks: list[asyncio.Task] = [] + + self.base_gps_coordinates = GpsCoordinates( + lat=36.910233, lon=-121.756897, alt=0.0 + ) + self.current_location: GpsCoordinates | None = None + + @property + def data_path(self) -> Path: + """The path to the data directory.""" + return self._data_path + + @property + def paths_path(self) -> Path: + """The path to the saved paths directory.""" + return "/data/farm_ng/autoplot_files/paths" + + @property + def guide_path(self) -> Path: + """The path to the saved paths directory.""" + return "/data/farm_ng/autoplot_files/guide" + + @property + def boundary_path(self) -> Path: + """The path to the saved paths directory.""" + return "/data/farm_ng/autoplot_files/boundary" + + def build(self) -> ScreenManager: + self.sm = ScreenManager() + self.sm.add_widget(MainScreen(name="main_screen")) + self.sm.add_widget(BoundaryScreen(name="boundary_screen")) + self.sm.add_widget(PathScreen(name="path_screen")) + # set the current screen to the MainScreen + self.sm.current = "main_screen" + return self.sm + + async def app_func(self) -> None: + async def run_wrapper() -> None: + # we don't actually need to set asyncio as the lib because it is + # the default, but it doesn't hurt to be explicit + await self.async_run(async_lib="asyncio") + for task in self.async_tasks: + task.cancel() + + # task to read from the gps + self.async_tasks.append(asyncio.create_task(self.stream_gps())) + + return await asyncio.gather(run_wrapper(), *self.async_tasks) + + async def stream_gps(self) -> None: + # TODO: implement this for real using the client + import torch + + while True: + # add some noise to the gps coordinates + lat, lon, alt = torch.randn(3).mul(0.0001).tolist() + self.current_location = GpsCoordinates( + lat=self.base_gps_coordinates.lat + lat, + lon=self.base_gps_coordinates.lon + lon, + alt=self.base_gps_coordinates.alt + alt, + ) + await asyncio.sleep(0.1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(prog="autoplot-app") + args = parser.parse_args() + + loop = asyncio.get_event_loop() + + app = AutoPlot(data_path=FARM_NG_DEFAULT_DATA_PATH) + + try: + loop.run_until_complete(app.app_func()) + except asyncio.CancelledError: + pass + finally: + loop.close() diff --git a/main_screen.py b/main_screen.py new file mode 100644 index 0000000..554c0b4 --- /dev/null +++ b/main_screen.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from kivy.app import App +from kivy.lang.builder import Builder +from kivy.properties import StringProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.screenmanager import Screen +from kivy.uix.tabbedpanel import TabbedPanelItem +from utils import FileDescription, get_list_of_files +from pathlib import Path + +Builder.load_string( + """ + + BoxLayout: + orientation: 'vertical' + Widget: + size_hint_y: 0.1 + RelativeLayout: + id: content_view + size_hint_y: 0.9 + TabbedPanel: + id: tabbed_panel + do_default_tab: False + FilesTabbedPanel: + id: guide_line_panel + text: 'Guidance Line' + on_press: root.update_tabs_scroll_views(app.guide_path) + FilesTabbedPanel: + id: path_panel + text: 'Path' + on_press: root.update_tabs_scroll_views(app.paths_path) + FilesTabbedPanel: + id: field_bound_panel + text: 'Field Boundary' + on_press: root.update_tabs_scroll_views(app.boundary_path) + Button: + id: tab_action_button + text: '+' + pos_hint: {'top': 1.0, 'right': 1.0} + size_hint: 0.1, 0.1 + on_release: root.on_tab_action_button() + +: + ScrollView: + id: scroll_view + do_scroll_x: False + GridLayout: + id: layout_grid + cols: 1 + size_hint_y: None + height: self.minimum_height + +: + orientation: 'horizontal' + size_hint_y: None + Label: + text: root.text +""" +) + +# ui classes + + +class FilesTabbedPanel(TabbedPanelItem): + pass + + +class FileDescriptionWidget(BoxLayout): + text = StringProperty() + + +class MainScreen(Screen): + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + # initialize tabbed panel + self.update_tabs_scroll_views(self.app.guide_path) + + @property + def app(self) -> App: + """Get app instance.""" + return App.get_running_app() + + # TODO: improve this later, now just to prove concept + def update_tabs_scroll_views(self,path) -> None: + """Update tabbed panel scroll views.""" + # get list of files to update + print(path) + data_path = Path(path) + files: list[FileDescription] = get_list_of_files(data_path) + + # update tabbed panel + for tab in self.ids.tabbed_panel.tab_list: + # get tab scroll view + tab_grid_layout: GridLayout = tab.ids.layout_grid + + # clear scroll view grid layout + tab_grid_layout.clear_widgets() + + # add files to scroll view grid layout + file: FileDescription + for file in files: + tab_grid_layout.add_widget( + FileDescriptionWidget( + text=f"{tab.text} -- {file.name} -- {file.size_bytes} bytes -- {file.modification_time_str}" + ) + ) + + def on_tab_action_button(self) -> None: + """Handle tab action button press. + + This method is called when the tab action button is pressed and redirects to the correct screen. + + """ + # get current tab text + current_tab_text: str = self.ids.tabbed_panel.current_tab.text.lower().replace( + " ", "_" + ) + + # change screen + if current_tab_text == "field_boundary": + self.manager.current = "boundary_screen" + self.manager.transition.direction = "left" + elif current_tab_text == "path": + self.manager.current = "path_screen" + self.manager.transition.direction = "left" + else: + print("do nothing for now") diff --git a/map_view.py b/map_view.py new file mode 100644 index 0000000..426c7dc --- /dev/null +++ b/map_view.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from pathlib import Path + +from farm_ng.gps import gps_pb2 +from farm_ng.gps_utils.coordinates import GpsCoordinates +from farm_ng.internal_widgets.mapview import AmigaMapView, map_marker_to_gps_proto +from kivy.app import App +from kivy.lang.builder import Builder +from kivy.uix.image import AsyncImage +from kivy.uix.relativelayout import RelativeLayout +from kivy.uix.scatter import Scatter + +CURRENT_DIR = Path(__file__).parent + + +Builder.load_string( + """ +: + BoxLayout: + orientation: 'horizontal' + Widget: + size_hint_x: 0.9 + BoxLayout: + id: map_controls_layout + orientation: 'vertical' + size_hint_x: 0.1 + BoxLayout: + orientation: 'vertical' + size_hint_y: 0.5 + Widget: + size_hint_y: 0.1 + Button: + id: compass_button + size_hint_y: 0.1 + text: 'N' + on_release: root.set_compass() + Widget: + size_hint_y: 0.3 + BoxLayout: + orientation: 'vertical' + size_hint_y: 0.5 + Button: + id: action_button + size_hint_y: 0.1 + text: 'O' + on_release: root.on_action_button() + Widget: + size_hint_y: 0.1 + Button: + id: zoom_in_button + size_hint_y: 0.1 + text: '+' + on_release: root.map_view.zoom_in() + Button: + id: zoom_out_button + size_hint_y: 0.1 + text: '-' + on_release: root.map_view.zoom_out() + +: + do_rotation: False + do_scale: True + do_translation: True +""" +) + + +class FullMapView(RelativeLayout): + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.map_view = AmigaMapView() + self.add_widget(self.map_view) + + # configure the map view + self.map_view.set_map_source("satellite") + + # center on the default settings + self.set_compass() + + @property + def app(self) -> App: + return App.get_running_app() + + # TODO: later possibly will do another thing + def set_compass(self) -> None: + """Center the map view on the current location.""" + if self.app.current_location is None: + print("current location is None") + return + self.map_view.center_on( + self.app.current_location.lat, + self.app.current_location.lon, + ) + self.map_view.zoom = self.map_view.default_zoom + + def on_action_button(self) -> None: + self.add_waypoint() + + def add_waypoint(self) -> None: + """Add a waypoint to the map view from the current location.""" + if self.app.current_location is None: + print("current location is None") + return + + # draw the waypoint on the map view + self.map_view.add_map_marker( + self.app.current_location.lat, + self.app.current_location.lon, + ) + + # draw the lines between the waypoints + if len(self.map_view.current_markers) > 1: + # get the coordinates of the markers + coordinates: list[tuple[float, float]] = [ + [marker.lon, marker.lat] for marker in self.map_view.current_markers + ] + + # create a new geojson layer + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "color": "blue", + "stroke-width": 2, + }, + "geometry": {"type": "LineString", "coordinates": coordinates}, + } + ], + } + + # update the geojson layer + self.map_view.geojson_layer.geojson = geojson + + # allow to finish the drawing + # TODO: this is not the best place to do this + self.parent.parent.parent.ids.finish_save_done_button.disabled = False + + def get_polygon_geojson(self) -> dict: + """Get the geojson of the polygon.""" + # get the coordinates of the markers + coordinates: list[tuple[float, float]] = [ + [marker.lon, marker.lat] for marker in self.map_view.current_markers + ] + # add the first point to close the polygon + coordinates.append(coordinates[0]) + + # create a new geojson layer + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "color": "blue", + "stroke-width": 2, + }, + "geometry": {"type": "LineString", "coordinates": coordinates}, + } + ], + } + return geojson + + def get_waypoints(self) -> list[gps_pb2.GpsFrame]: + """Get the waypoints from the map view.""" + return [ + map_marker_to_gps_proto(marker) for marker in self.map_view.current_markers + ] + + +# TODO: finish me ... easy to go with AmigaMapView +class FakeMapViewScatter(Scatter): + """A fake map view for testing purposes.""" + + default_source = str(CURRENT_DIR / "dummy_map.jpeg") + default_zoom: float = 1.0 + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # set the background image + # with self.canvas: + # Color(1, 1, 1, 1) # white + # self.rect = Rectangle(size=self.size, pos=self.pos) + + # NOTE: just to showcase how to add an image to the map view + # TODO: this is not geo-referenced and should be replaced with a real map + # from a map provider. + self.add_widget(AsyncImage(source=self.default_source)) + + @property + def app(self) -> App: + return App.get_running_app() + + def draw_waypoint(self, waypoint: GpsCoordinates) -> None: + """Draw a waypoint on the map view""" + pass diff --git a/path_screen.py b/path_screen.py new file mode 100644 index 0000000..f0e7083 --- /dev/null +++ b/path_screen.py @@ -0,0 +1,80 @@ +from kivy.lang.builder import Builder +from kivy.uix.screenmanager import Screen + +Builder.load_string( + """ + + BoxLayout: + orientation: 'vertical' + BoxLayout: + id: content_view + size_hint_y: 0.9 + RelativeLayout: + BoxLayout: + orientation: 'horizontal' + Widget: + size_hint_x: 0.9 + BoxLayout: + id: map_controls_layout + orientation: 'vertical' + size_hint_x: 0.1 + BoxLayout: + orientation: 'vertical' + size_hint_y: 0.5 + Widget: + size_hint_y: 0.1 + Button: + id: compass_button + size_hint_y: 0.1 + text: 'N' + Widget: + size_hint_y: 0.3 + BoxLayout: + orientation: 'vertical' + size_hint_y: 0.5 + Button: + id: action_button + size_hint_y: 0.1 + text: 'O' + Widget: + size_hint_y: 0.1 + Button: + id: zoom_in_button + size_hint_y: 0.1 + text: '+' + Button: + id: zoom_out_button + size_hint_y: 0.1 + text: '-' + BoxLayout: + id: bottom_menu_view + size_hint_y: 0.1 + orientation: 'horizontal' + BoxLayout: + id: left_menu_view + size_hint_x: 0.8 + Label: + id: status_label + text: 'Drive Amiga to ...' + BoxLayout: + id: right_menu_view + size_hint_x: 0.2 + orientation: 'horizontal' + Widget: + size_hint_x: 0.5 + Button: + id: cancel_button + text: 'Cancel' + on_release: root.on_back_to_main_button() +""" +) + + +class PathScreen(Screen): + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + + def on_back_to_main_button(self) -> None: + """Go back to main screen.""" + self.manager.current = "main_screen" + self.manager.transition.direction = "right" diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..0aabff8 --- /dev/null +++ b/utils.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import datetime +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class FileDescription: + """Description of a file. + + Attributes: + name (str): the name of the file + size_bytes (int): the size of the file in bytes + modification_time (float): the modification time of the file + """ + + name: str + size_bytes: int + modification_time: float + + @property + def modification_time_str(self) -> str: + """Get modification time as string in format: YYYY-MM-DD HH:MM:SS. + + Returns: + str: modification time as string + """ + return datetime.datetime.fromtimestamp(self.modification_time).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + +def get_list_of_files(data_path: Path) -> list[FileDescription]: + """Get list of files in data path. + + Args: + data_path (Path): path to data directory + + Returns: + list[FileDescription]: list of files in data directory + """ + files: list[FileDescription] = [] + file: Path + for file in data_path.iterdir(): + if not file.is_file(): + # print("fake file") + continue + files.append( + FileDescription( + name=file.name, + size_bytes=file.stat().st_size, + modification_time=file.stat().st_mtime, + ) + ) + + return files From 9e5496fff689995eb21cad9461405eeaa89390bd Mon Sep 17 00:00:00 2001 From: edgarriba Date: Tue, 18 Jul 2023 17:26:47 -0700 Subject: [PATCH 3/3] WIP: select files from file prompt --- main_screen.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/main_screen.py b/main_screen.py index 554c0b4..10ca964 100644 --- a/main_screen.py +++ b/main_screen.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from kivy.app import App from kivy.lang.builder import Builder from kivy.properties import StringProperty @@ -8,7 +10,6 @@ from kivy.uix.screenmanager import Screen from kivy.uix.tabbedpanel import TabbedPanelItem from utils import FileDescription, get_list_of_files -from pathlib import Path Builder.load_string( """ @@ -41,7 +42,6 @@ pos_hint: {'top': 1.0, 'right': 1.0} size_hint: 0.1, 0.1 on_release: root.on_tab_action_button() - : ScrollView: id: scroll_view @@ -51,13 +51,15 @@ cols: 1 size_hint_y: None height: self.minimum_height - : orientation: 'horizontal' size_hint_y: None - Label: + Button: + id: root.id text: root.text + on_release: root.printing(root.text, root.id) """ + ) # ui classes @@ -68,22 +70,36 @@ class FilesTabbedPanel(TabbedPanelItem): class FileDescriptionWidget(BoxLayout): + id = StringProperty() text = StringProperty() - + + def printing(self, text, id): + print('Hello, you tapped a button', text) + print('id is', id) + print('Loading other file') + + # TODO URGENT Find a better way to change screens + # self.parent.parent.parent.parent.parent.parent.parent.parent.current = "boundary_screen" + # print(self.parent) + class MainScreen(Screen): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) # initialize tabbed panel + # methods = [method_name for method_name in dir(MainScreen) if callable(getattr(MainScreen, method_name))] + # print(methods) + self.update_tabs_scroll_views(self.app.guide_path) @property def app(self) -> App: """Get app instance.""" return App.get_running_app() + # TODO: improve this later, now just to prove concept - def update_tabs_scroll_views(self,path) -> None: + def update_tabs_scroll_views(self, path) -> None: """Update tabbed panel scroll views.""" # get list of files to update print(path) @@ -103,15 +119,14 @@ def update_tabs_scroll_views(self,path) -> None: for file in files: tab_grid_layout.add_widget( FileDescriptionWidget( - text=f"{tab.text} -- {file.name} -- {file.size_bytes} bytes -- {file.modification_time_str}" + id = file.name, + text = f"{tab.text} -- {file.name} -- {file.size_bytes} bytes -- {file.modification_time_str}" ) ) def on_tab_action_button(self) -> None: """Handle tab action button press. - This method is called when the tab action button is pressed and redirects to the correct screen. - """ # get current tab text current_tab_text: str = self.ids.tabbed_panel.current_tab.text.lower().replace( @@ -126,4 +141,4 @@ def on_tab_action_button(self) -> None: self.manager.current = "path_screen" self.manager.transition.direction = "left" else: - print("do nothing for now") + print("do nothing for now") \ No newline at end of file