diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c98345e..12a0d61 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -12,7 +12,18 @@ on: jobs: build: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: + - gh: ubuntu-22.04 + cs: linux-x64 + - gh: windows-latest + cs: win-x64 + - gh: macos-latest + cs: osx-x64 + + runs-on: ${{ matrix.os.gh }} steps: - uses: actions/checkout@v4 @@ -27,8 +38,32 @@ jobs: - name: Restore dependencies run: dotnet restore - - name: Build + - if: matrix.os.cs != 'win-x64' + name: Build run: dotnet build --no-restore -c Release - name: Test run: dotnet test --verbosity normal + + - name: Publish CLI + run: dotnet publish SlimeVrOta -c Release -r ${{ matrix.os.cs }} -o builds/cli_${{ matrix.os.cs }} -p:PublishAot=true -p:PublishSingleFile=false -p:CSharpier_Bypass=true + + - name: Upload a build artifact (CLI) + uses: actions/upload-artifact@v4 + with: + name: SlimeVR-OTA_${{ matrix.os.cs }} + # A file, directory or wildcard pattern that describes what to upload + path: builds/cli_${{ matrix.os.cs }}/ + + - name: Publish GUI + run: dotnet publish SlimeVrOta.Gui -c Release -r ${{ matrix.os.cs }} -o builds/gui_${{ matrix.os.cs }} -p:CSharpier_Bypass=true + + - name: Upload a build artifact (GUI) + uses: actions/upload-artifact@v4 + with: + name: SlimeVR-OTA-GUI_${{ matrix.os.cs }} + # A file, directory or wildcard pattern that describes what to upload + path: | + builds/gui_${{ matrix.os.cs }}/ + !builds/gui_${{ matrix.os.cs }}/SlimeVR-OTA + !builds/gui_${{ matrix.os.cs }}/SlimeVR-OTA.* diff --git a/README.md b/README.md index 8708b3d..087d48b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # SlimeVR-OTA-CLI - A simple CLI to flash SlimeVR ESPs OTA. + +A simple CLI to flash SlimeVR ESPs OTA. diff --git a/SlimeVrOta.Gui/.editorconfig b/SlimeVrOta.Gui/.editorconfig new file mode 100644 index 0000000..d5477ff --- /dev/null +++ b/SlimeVrOta.Gui/.editorconfig @@ -0,0 +1,153 @@ +############################### +# Core EditorConfig Options # +############################### +root = true + +# All files +[*] +indent_style = space +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 4 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 4 + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + +############################### +# VB Coding Conventions # +############################### +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion diff --git a/SlimeVrOta.Gui/.gitignore b/SlimeVrOta.Gui/.gitignore new file mode 100644 index 0000000..104b544 --- /dev/null +++ b/SlimeVrOta.Gui/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/SlimeVrOta.Gui/App.axaml b/SlimeVrOta.Gui/App.axaml new file mode 100644 index 0000000..6b4581e --- /dev/null +++ b/SlimeVrOta.Gui/App.axaml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/SlimeVrOta.Gui/App.axaml.cs b/SlimeVrOta.Gui/App.axaml.cs new file mode 100644 index 0000000..5b6c0a0 --- /dev/null +++ b/SlimeVrOta.Gui/App.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace SlimeVrOta.Gui +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/SlimeVrOta.Gui/Assets/icon.ico b/SlimeVrOta.Gui/Assets/icon.ico new file mode 100644 index 0000000..bdbd894 Binary files /dev/null and b/SlimeVrOta.Gui/Assets/icon.ico differ diff --git a/SlimeVrOta.Gui/Assets/icon.png b/SlimeVrOta.Gui/Assets/icon.png new file mode 100644 index 0000000..161c134 Binary files /dev/null and b/SlimeVrOta.Gui/Assets/icon.png differ diff --git a/SlimeVrOta.Gui/MainWindow.axaml b/SlimeVrOta.Gui/MainWindow.axaml new file mode 100644 index 0000000..53ec271 --- /dev/null +++ b/SlimeVrOta.Gui/MainWindow.axaml @@ -0,0 +1,89 @@ + + + + + + + WhiteSmoke + #e2d8eb + 1 1 8 2 #c5c5c5 + #d080ff + + + #252525 + #383442 + 1 1 8 2 #161616 + #a070d0 + + + + + + + + + + + No firmware selected... + + + + + + + + + Waiting for tracker to connect... + + + + + + Idle... + + + + + diff --git a/SlimeVrOta.Gui/MainWindow.axaml.cs b/SlimeVrOta.Gui/MainWindow.axaml.cs new file mode 100644 index 0000000..37a9ef3 --- /dev/null +++ b/SlimeVrOta.Gui/MainWindow.axaml.cs @@ -0,0 +1,405 @@ +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Platform.Storage; + +namespace SlimeVrOta.Gui +{ + public partial class MainWindow : Window + { + public enum FlashState + { + Connecting, + Ready, + Flashing, + Waiting, + Done, + Error + } + + public FilePickerFileType ZipFileType = + new("ZIP archive") + { + Patterns = ["*.zip"], + AppleUniformTypeIdentifiers = ["public.zip-archive"], + MimeTypes = ["application/zip", "x-zip-compressed"], + }; + + public FilePickerFileType BinFileType = + new("Firmware binary") + { + Patterns = ["*.bin"], + AppleUniformTypeIdentifiers = ["public.data"], + MimeTypes = ["application/octet-stream"], + }; + + private IStorageFile? _firmwareFile; + + private static readonly int _port = 6969; + private readonly byte[] _udpBuffer = new byte[65535]; + private static readonly IPEndPoint _localEndPoint = new(IPAddress.Any, _port); + + private Socket? _socket; + private IPEndPoint _endPoint = new(IPAddress.Any, _port); + + private FlashState _currentState = FlashState.Connecting; + public FlashState CurrentState + { + get => _currentState; + set + { + _currentState = value; + UpdateFileStatus(); + UpdateTrackerStatus(); + UpdateFlashStatus(); + } + } + + public bool IsReady => + CurrentState == FlashState.Ready + || CurrentState == FlashState.Done + || CurrentState == FlashState.Error; + public bool CanAcceptFile => CurrentState == FlashState.Connecting || IsReady; + + public MainWindow() + { + InitializeComponent(); + + FirmwareDropBox.AddHandler(DragDrop.DropEvent, SelectFirmwareDrop); + SelectFileButton.Click += SelectFirmwareBrowse; + RemoveTrackerButton.Click += RemoveTracker; + FlashButton.Click += FlashFirmware; + + _ = ReceiveHandshake(); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + + MinWidth = Width + 100; + MinHeight = Height; + MaxHeight = Height; + + SizeToContent = SizeToContent.Manual; + } + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + + _socket?.Dispose(); + _socket = null; + } + + private void UpdateFileStatus() + { + if (CanAcceptFile) + { + SelectFileButton.IsEnabled = true; + DragDrop.SetAllowDrop(FirmwareDropBox, true); + } + else + { + SelectFileButton.IsEnabled = false; + DragDrop.SetAllowDrop(FirmwareDropBox, false); + } + } + + private void SelectFile(IStorageFile file) + { + if (!CanAcceptFile) + return; + + _firmwareFile = file; + + var path = Uri.UnescapeDataString(file.Path.AbsolutePath); + FirmwareFileText.Text = path; + ToolTip.SetTip(FirmwareFileText, path); + + UpdateFlashStatus(); + } + + public void SelectFirmwareDrop(object? sender, DragEventArgs e) + { + if (!CanAcceptFile) + return; + + if (e.Data.Contains(DataFormats.Files)) + { + var files = e.Data.GetFiles(); + var file = + files?.FirstOrDefault(file => + file is IStorageFile + && ( + file.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) + || file.Name.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) + ) + ) as IStorageFile; + if (file != null) + { + Debug.WriteLine("Dropped file(s)"); + SelectFile(file); + } + } + } + + public async void SelectFirmwareBrowse(object? sender, RoutedEventArgs e) + { + if (!CanAcceptFile) + return; + + var files = await StorageProvider.OpenFilePickerAsync( + new FilePickerOpenOptions() + { + Title = "Select firmware file...", + FileTypeFilter = [ZipFileType, BinFileType], + AllowMultiple = false, + } + ); + var file = files?.Count > 0 ? files[0] : null; + + if (file != null) + { + Debug.WriteLine("Selected file"); + SelectFile(file); + } + } + + private void UpdateTrackerStatus() + { + if (CurrentState == FlashState.Connecting) + { + RemoveTrackerButton.IsEnabled = false; + TrackerStatusText.Text = "Waiting for tracker to connect..."; + } + else + { + RemoveTrackerButton.IsEnabled = IsReady; + TrackerStatusText.Text = _endPoint.ToString(); + } + } + + public async Task ReceiveHandshake(CancellationToken cancelToken = default) + { + if (_socket == null) + { + _socket = new Socket( + AddressFamily.InterNetwork, + SocketType.Dgram, + ProtocolType.Udp + ); + _socket.Bind(_localEndPoint); + } + + while (!cancelToken.IsCancellationRequested && _socket.IsBound) + { + try + { + // Clear socket buffer + while (!cancelToken.IsCancellationRequested && _socket.Available > 0) + { + await _socket.ReceiveFromAsync(_udpBuffer, _endPoint, cancelToken); + } + + var data = await _socket.ReceiveFromAsync(_udpBuffer, _endPoint, cancelToken); + if ( + data.ReceivedBytes < 4 + || data.RemoteEndPoint is not IPEndPoint remoteEndpoint + ) + { + throw new Exception( + $"Received an invalid SlimeVR packet on port {_port} from {data.RemoteEndPoint}." + ); + } + + var packetType = BinaryPrimitives.ReadUInt32BigEndian(_udpBuffer); + // 3 is a handshake packet + if (packetType != 3) + { + throw new Exception( + $"Received a non-handshake packet on port {_port} from {data.RemoteEndPoint}." + ); + } + + if (CurrentState == FlashState.Connecting) + { + _endPoint = remoteEndpoint; + CurrentState = FlashState.Ready; + } + + break; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + } + } + } + + public void RemoveTracker(object? sender, RoutedEventArgs e) + { + if (!IsReady) + return; + + _socket?.Dispose(); + _socket = null; + _endPoint = new(IPAddress.Any, _port); + CurrentState = FlashState.Connecting; + + _ = ReceiveHandshake(); + } + + private void UpdateFlashStatus() + { + if (_firmwareFile != null && IsReady) + { + FlashButton.IsEnabled = true; + switch (CurrentState) + { + case FlashState.Ready: + FlashStatusText.Text = "Ready to flash..."; + FlashProgress.Value = 0.0; + break; + case FlashState.Done: + FlashStatusText.Text = + "Successfully flashed tracker! Test before turning off!"; + FlashProgress.Value = 100.0; + break; + case FlashState.Error: + FlashStatusText.Text = "Failed to flash tracker."; + break; + } + } + else + { + FlashButton.IsEnabled = false; + + switch (CurrentState) + { + case FlashState.Connecting: + FlashStatusText.Text = "Idle..."; + FlashProgress.Value = 0.0; + break; + case FlashState.Flashing: + FlashStatusText.Text = "Flashing tracker... DO NOT TURN OFF!"; + break; + case FlashState.Waiting: + FlashStatusText.Text = "Waiting for tracker... DO NOT TURN OFF!"; + break; + } + } + } + + public async void FlashFirmware(object? sender, RoutedEventArgs e) + { + if (_firmwareFile == null || !IsReady) + return; + + Debug.WriteLine("Flashing firmware"); + CurrentState = FlashState.Flashing; + FlashProgress.Value = 0.0; + + try + { + using var fileStream = await _firmwareFile.OpenReadAsync(); + var fileName = _firmwareFile.Name; + byte[] fileBytes; + + // Handle ZIP + if (_firmwareFile.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + using var zipFile = new ZipArchive(fileStream); + var file = + zipFile.GetEntry("firmware-part-0.bin") + ?? throw new Exception( + "Could not retrieve firmware binary \"firmware-part-0.bin\" from ZIP archive." + ); + fileName = file.Name; + + using var zipFileStream = file.Open(); + using var memoryStream = new MemoryStream((int)file.Length); + await zipFileStream.CopyToAsync(memoryStream); + fileBytes = memoryStream.ToArray(); + } + else if (_firmwareFile.Name.EndsWith(".bin", StringComparison.OrdinalIgnoreCase)) + { + var fileSize = + (await _firmwareFile.GetBasicPropertiesAsync()).Size ?? 1048576UL; + + using var memoryStream = new MemoryStream((int)fileSize); + await fileStream.CopyToAsync(memoryStream); + fileBytes = memoryStream.ToArray(); + } + else + { + throw new Exception("Unexpected firmware file type."); + } + + Debug.WriteLine($"Loaded file \"{fileName}\" ({fileBytes.Length} bytes)"); + + var progress = new Progress<(int cur, int max)>(val => + { + FlashProgress.Value = (val.cur / (double)val.max) * 50.0; + }); + await new EspOta().Serve( + new IPEndPoint(_endPoint.Address, 8266), + new IPEndPoint(IPAddress.Any, 0), + fileName, + fileBytes, + "SlimeVR-OTA", + EspOta.OtaCommands.FLASH, + progress + ); + + Debug.WriteLine("Waiting for tracker response"); + CurrentState = FlashState.Waiting; + + using var cancelSrc = new CancellationTokenSource(); + var handshake = ReceiveHandshake(cancelSrc.Token); + // 45 second timeout, it should not take that long + cancelSrc.CancelAfter(45000); + + using var timerCancel = new CancellationTokenSource(); + try + { + handshake.GetAwaiter().OnCompleted(timerCancel.Cancel); + while ( + !timerCancel.IsCancellationRequested + && !handshake.IsCompleted + && FlashProgress.Value < 95.0 + ) + { + await Task.Delay(300, timerCancel.Token); + FlashProgress.Value += 1.0; + } + } + catch + { + // Ignore, this is just to wait for the handshake to finish + } + + await handshake; + + Debug.WriteLine("Firmware flashed successfully"); + FlashProgress.Value = 100.0; + CurrentState = FlashState.Done; + } + catch (Exception ex) + { + Debug.WriteLine(ex); + CurrentState = FlashState.Error; + } + } + } +} diff --git a/SlimeVrOta.Gui/Program.cs b/SlimeVrOta.Gui/Program.cs new file mode 100644 index 0000000..e464f05 --- /dev/null +++ b/SlimeVrOta.Gui/Program.cs @@ -0,0 +1,19 @@ +using System; +using Avalonia; + +namespace SlimeVrOta.Gui +{ + internal class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace(); + } +} diff --git a/SlimeVrOta.Gui/SlimeVrOta.Gui.csproj b/SlimeVrOta.Gui/SlimeVrOta.Gui.csproj new file mode 100644 index 0000000..a6fb8ed --- /dev/null +++ b/SlimeVrOta.Gui/SlimeVrOta.Gui.csproj @@ -0,0 +1,43 @@ + + + + WinExe + net8.0 + enable + true + app.manifest + true + SlimeVR-OTA-GUI + Assets\icon.ico + true + true + true + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/SlimeVrOta.Gui/app.manifest b/SlimeVrOta.Gui/app.manifest new file mode 100644 index 0000000..6992db8 --- /dev/null +++ b/SlimeVrOta.Gui/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/SlimeVrOta.sln b/SlimeVrOta.sln index df0f9ca..1634ec3 100644 --- a/SlimeVrOta.sln +++ b/SlimeVrOta.sln @@ -1,10 +1,11 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.8.34525.116 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimeVrOta", "SlimeVrOta\SlimeVrOta.csproj", "{7B16BFEC-274F-4BB5-81E9-A26FF3FEDA8F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlimeVrOta.Gui", "SlimeVrOta.Gui\SlimeVrOta.Gui.csproj", "{BB15E5E3-258E-47C8-9906-A742C45859FA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +16,10 @@ Global {7B16BFEC-274F-4BB5-81E9-A26FF3FEDA8F}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B16BFEC-274F-4BB5-81E9-A26FF3FEDA8F}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B16BFEC-274F-4BB5-81E9-A26FF3FEDA8F}.Release|Any CPU.Build.0 = Release|Any CPU + {BB15E5E3-258E-47C8-9906-A742C45859FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB15E5E3-258E-47C8-9906-A742C45859FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB15E5E3-258E-47C8-9906-A742C45859FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB15E5E3-258E-47C8-9906-A742C45859FA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SlimeVrOta/.editorconfig b/SlimeVrOta/.editorconfig new file mode 100644 index 0000000..d5477ff --- /dev/null +++ b/SlimeVrOta/.editorconfig @@ -0,0 +1,153 @@ +############################### +# Core EditorConfig Options # +############################### +root = true + +# All files +[*] +indent_style = space +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 4 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 4 + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 + +############################### +# .NET Coding Conventions # +############################### +[*.{cs,vb}] +# Organize usings +dotnet_sort_system_directives_first = true + +# this. preferences +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent + +############################### +# Naming Conventions # +############################### +# Style Definitions +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Use PascalCase for constant fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.applicable_accessibilities = * +dotnet_naming_symbols.constant_fields.required_modifiers = const + +############################### +# C# Coding Conventions # +############################### +[*.cs] +# var preferences +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion + +# Null-checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion + +# Expression-level preferences +csharp_prefer_braces = true:silent +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +############################### +# C# Formatting Rules # +############################### +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping preferences +csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true + +############################### +# VB Coding Conventions # +############################### +[*.vb] +# Modifier preferences +visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion diff --git a/SlimeVrOta/EspOta.cs b/SlimeVrOta/EspOta.cs index c00a296..665ae86 100644 --- a/SlimeVrOta/EspOta.cs +++ b/SlimeVrOta/EspOta.cs @@ -5,7 +5,7 @@ namespace SlimeVrOta { - public static class EspOta + public class EspOta { public enum OtaCommands { @@ -14,6 +14,9 @@ public enum OtaCommands AUTH = 200, } + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); + public int TimeoutMs => (int)Timeout.TotalMilliseconds; + private static string Md5Hash(ReadOnlySpan bytes) { return Convert.ToHexString(MD5.HashData(bytes)).ToLower(); @@ -24,88 +27,23 @@ private static string Md5Hash(string text) return Md5Hash(Encoding.UTF8.GetBytes(text)); } - public static Task Serve( - IPEndPoint remoteEndPoint, - string fileName, - byte[] fileData, - string auth = "", - OtaCommands command = OtaCommands.FLASH, - CancellationToken cancelToken = default - ) - { - return Serve( - remoteEndPoint, - new IPEndPoint(IPAddress.Any, 0), - fileName, - fileData, - auth, - command, - cancelToken - ); - } - - public static Task Serve( + public async Task Serve( IPEndPoint remoteEndPoint, IPEndPoint localEndPoint, string fileName, byte[] fileData, string auth = "", OtaCommands command = OtaCommands.FLASH, - CancellationToken cancelToken = default - ) - { - return Serve( - remoteEndPoint, - localEndPoint, - fileName, - fileData, - TimeSpan.FromSeconds(10), - auth, - command, - cancelToken - ); - } - - public static Task Serve( - IPEndPoint remoteEndPoint, - string fileName, - byte[] fileData, - TimeSpan timeout, - string auth = "", - OtaCommands command = OtaCommands.FLASH, - CancellationToken cancelToken = default - ) - { - return Serve( - remoteEndPoint, - new IPEndPoint(IPAddress.Any, 0), - fileName, - fileData, - timeout, - auth, - command, - cancelToken - ); - } - - public static async Task Serve( - IPEndPoint remoteEndPoint, - IPEndPoint localEndPoint, - string fileName, - byte[] fileData, - TimeSpan timeout, - string auth = "", - OtaCommands command = OtaCommands.FLASH, + IProgress<(int cur, int max)>? progress = null, CancellationToken cancelToken = default ) { Console.WriteLine("Starting OTA..."); - var timeoutMs = (int)timeout.TotalMilliseconds; using var listener = new TcpListener(localEndPoint); listener.Server.NoDelay = true; - listener.Server.SendTimeout = timeoutMs; - listener.Server.ReceiveTimeout = timeoutMs; + listener.Server.SendTimeout = TimeoutMs; + listener.Server.ReceiveTimeout = TimeoutMs; listener.Start(); var listenerEndPoint = (IPEndPoint)listener.LocalEndpoint; @@ -113,8 +51,8 @@ public static async Task Serve( var fileMd5 = Md5Hash(fileData); using var initClient = new UdpClient(); - initClient.Client.SendTimeout = timeoutMs; - initClient.Client.ReceiveTimeout = timeoutMs; + initClient.Client.SendTimeout = TimeoutMs; + initClient.Client.ReceiveTimeout = TimeoutMs; initClient.Connect(remoteEndPoint); await initClient.SendAsync( @@ -130,13 +68,13 @@ await initClient.SendAsync( using var receiveTimeout = CancellationTokenSource.CreateLinkedTokenSource( cancelToken ); - receiveTimeout.CancelAfter(timeout); + receiveTimeout.CancelAfter(Timeout); initResponse = await initClient.ReceiveAsync(receiveTimeout.Token); } - catch (Exception) + catch (Exception ex) { Console.WriteLine("OTA request failed, no response"); - return false; + throw new OtaException("OTA request failed, no response.", ex); } var initResponseText = Encoding.UTF8.GetString(initResponse.Buffer); @@ -161,26 +99,28 @@ await initClient.SendAsync( using var authTimeout = CancellationTokenSource.CreateLinkedTokenSource( cancelToken ); - authTimeout.CancelAfter(timeout); + authTimeout.CancelAfter(Timeout); authResponse = await initClient.ReceiveAsync(authTimeout.Token); } - catch (Exception) + catch (Exception ex) { Console.WriteLine("Auth failed, no response"); - return false; + throw new OtaException("Auth failed, no response.", ex); } var authResponseText = Encoding.UTF8.GetString(authResponse.Buffer); if (authResponseText != "OK") { Console.WriteLine($"Auth failed, bad response: {authResponseText}"); - return false; + throw new OtaException( + $"Auth failed, bad response: \"{authResponseText}\"" + ); } } else { Console.WriteLine($"Bad response: {initResponseText}"); - return false; + throw new OtaException($"Bad response: \"{initResponseText}\""); } } @@ -191,24 +131,24 @@ await initClient.SendAsync( using var acceptTimeout = CancellationTokenSource.CreateLinkedTokenSource( cancelToken ); - acceptTimeout.CancelAfter(timeout); + acceptTimeout.CancelAfter(Timeout); device = await listener.AcceptTcpClientAsync(acceptTimeout.Token); } - catch (Exception) + catch (Exception ex) { Console.WriteLine("No response from device"); - return false; + throw new OtaException("No response from device.", ex); } if (!device.Connected) { Console.WriteLine("Device did not connect"); - return false; + throw new OtaException("Device did not connect."); } Console.WriteLine("Connected to device, sending firmware"); - device.ReceiveTimeout = timeoutMs; - device.SendTimeout = timeoutMs; + device.ReceiveTimeout = TimeoutMs; + device.SendTimeout = TimeoutMs; using var fileStream = new MemoryStream(fileData); var bytesWritten = 0; @@ -223,30 +163,31 @@ await initClient.SendAsync( Console.WriteLine( $"Written {bytesWritten} out of {contentSize} ({bytesWritten / (float)contentSize:0.0%})" ); + progress?.Report((bytesWritten, contentSize)); try { using var responseTimeout = CancellationTokenSource.CreateLinkedTokenSource( cancelToken ); - responseTimeout.CancelAfter(timeout); + responseTimeout.CancelAfter(Timeout); var responseSize = await device.Client.ReceiveAsync( buffer.AsMemory(0, 10), responseTimeout.Token ); response = Encoding.UTF8.GetString(buffer.AsSpan(0, responseSize)); } - catch (Exception) + catch (Exception ex) { Console.WriteLine("Lost connection while writing firmware"); - return false; + throw new OtaException("Lost connection while writing firmware.", ex); } } if (response.Contains("OK")) { Console.WriteLine("Success"); - return true; + return; } Console.WriteLine("Waiting for response..."); @@ -258,29 +199,29 @@ await initClient.SendAsync( using var responseTimeout = CancellationTokenSource.CreateLinkedTokenSource( cancelToken ); - responseTimeout.CancelAfter(timeout); + responseTimeout.CancelAfter(Timeout); var responseSize = await device.Client.ReceiveAsync( buffer.AsMemory(0, 32), responseTimeout.Token ); response = Encoding.UTF8.GetString(buffer.AsSpan(0, responseSize)); } - catch (Exception) + catch (Exception ex) { Console.WriteLine("Lost connection while waiting for response"); - return false; + throw new OtaException("Lost connection while waiting for response.", ex); } Console.WriteLine($"Result: {response}"); if (response.Contains("OK")) { Console.WriteLine("Success"); - return true; + return; } } Console.WriteLine("Error response from device"); - return false; + throw new OtaException("Error response from device."); } } } diff --git a/SlimeVrOta/OtaException.cs b/SlimeVrOta/OtaException.cs new file mode 100644 index 0000000..a1295a7 --- /dev/null +++ b/SlimeVrOta/OtaException.cs @@ -0,0 +1,13 @@ +namespace SlimeVrOta +{ + public class OtaException : Exception + { + public OtaException() { } + + public OtaException(string? message) + : base(message) { } + + public OtaException(string? message, Exception? innerException) + : base(message, innerException) { } + } +} diff --git a/SlimeVrOta/Program.cs b/SlimeVrOta/Program.cs index 8fe362d..f2b85bb 100644 --- a/SlimeVrOta/Program.cs +++ b/SlimeVrOta/Program.cs @@ -15,52 +15,75 @@ } var port = 6969; + var udpBuffer = new byte[65535]; + using var slimeSocket = new Socket( + AddressFamily.InterNetwork, + SocketType.Dgram, + ProtocolType.Udp + ); + slimeSocket.Bind(new IPEndPoint(IPAddress.Any, port)); var endPoint = new IPEndPoint(IPAddress.Any, port); - using var slimeClient = new UdpClient(port); - Console.WriteLine("Waiting to receive tracker handshake..."); + async Task WaitForHandshake() + { + // Clear socket buffer + while (slimeSocket.Available > 0) + { + await slimeSocket.ReceiveFromAsync(udpBuffer, endPoint); + } - var data = slimeClient.Receive(ref endPoint); - var packetType = BinaryPrimitives.ReadUInt32BigEndian(data); + var data = await slimeSocket.ReceiveFromAsync(udpBuffer, endPoint); + if (data.ReceivedBytes < 4 || data.RemoteEndPoint is not IPEndPoint receivedEndPoint) + { + throw new Exception( + $"Received an invalid SlimeVR packet on port {port} from {data.RemoteEndPoint}." + ); + } - // Handshake packet - if (packetType != 3) - { - throw new Exception($"Received a non-handshake packet on {port} from {endPoint}."); + var packetType = BinaryPrimitives.ReadUInt32BigEndian(udpBuffer); + // 3 is a handshake packet + if (packetType != 3) + { + throw new Exception( + $"Received a non-handshake packet on port {port} from {data.RemoteEndPoint}." + ); + } + + endPoint = receivedEndPoint; } - Console.WriteLine($"Received a handshake packet on {port} from {endPoint}."); - Console.WriteLine("Press enter to flash the tracker..."); + Console.WriteLine("Waiting to receive tracker handshake..."); + await WaitForHandshake(); + Console.WriteLine($"Received a handshake packet on port {port} from {endPoint}."); + + Console.WriteLine( + "Press enter to flash the tracker...\nWARNING: Do NOT turn off your tracker while flashing! Ensure the tracker is functioning after flashing before turning it off or proceeding to flash another tracker." + ); Console.ReadLine(); - var flashResult = EspOta - .Serve( + try + { + await new EspOta().Serve( new IPEndPoint(endPoint.Address, 8266), + new IPEndPoint(IPAddress.Any, 0), file.Name, - File.ReadAllBytes(file.FullName), + await File.ReadAllBytesAsync(file.FullName), "SlimeVR-OTA", EspOta.OtaCommands.FLASH - ) - .Result; - - if (!flashResult) + ); + } + catch (Exception ex) { - throw new Exception($"Failed to flash tracker {endPoint}."); + throw new OtaException($"Failed to flash tracker {endPoint}.", ex); } Console.WriteLine("Waiting to receive post-flash handshake..."); + await WaitForHandshake(); + Console.WriteLine($"Received a handshake packet on port {port} from {endPoint}."); - data = slimeClient.Receive(ref endPoint); - packetType = BinaryPrimitives.ReadUInt32BigEndian(data); - - // Handshake packet - if (packetType != 3) - { - throw new Exception($"Received a non-handshake packet on {port} from {endPoint}."); - } - Console.WriteLine($"Received a handshake packet on {port} from {endPoint}."); - - Console.WriteLine($"Tracker {endPoint} has been flashed successfully."); + Console.WriteLine( + $"Tracker {endPoint} has been flashed successfully.\nWARNING: Please test your tracker before turning it off or proceeding to flash another tracker!" + ); Console.WriteLine("Press any key to exit..."); Console.ReadKey(); } diff --git a/SlimeVrOta/SlimeVrOta.csproj b/SlimeVrOta/SlimeVrOta.csproj index cf4fae6..482000c 100644 --- a/SlimeVrOta/SlimeVrOta.csproj +++ b/SlimeVrOta/SlimeVrOta.csproj @@ -1,16 +1,24 @@ - + Exe net8.0 enable enable - True SlimeVR-OTA + icon.ico + true + true + true + true - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SlimeVrOta/icon.ico b/SlimeVrOta/icon.ico new file mode 100644 index 0000000..bdbd894 Binary files /dev/null and b/SlimeVrOta/icon.ico differ