diff --git a/.run/BTCPayServer_ Bitcoin-HTTPS.run.xml b/.run/BTCPayServer_ Bitcoin-HTTPS.run.xml
index 8e299d0..aeefb84 100644
--- a/.run/BTCPayServer_ Bitcoin-HTTPS.run.xml
+++ b/.run/BTCPayServer_ Bitcoin-HTTPS.run.xml
@@ -12,6 +12,7 @@
+
\ No newline at end of file
diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln
index 67a81c8..0c7752a 100644
--- a/BTCPayServerPlugins.sln
+++ b/BTCPayServerPlugins.sln
@@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.MicroN
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Subscriptions", "Plugins\BTCPayServer.Plugins.Subscriptions\BTCPayServer.Plugins.Subscriptions.csproj", "{994E5D32-849B-4276-82A9-2A18DBC98D39}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.BitcoinSwitch", "Plugins\BTCPayServer.Plugins.BitcoinSwitch\BTCPayServer.Plugins.BitcoinSwitch.csproj", "{B4688F11-33F9-41AB-846E-09CE9ECF3E08}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -293,6 +295,14 @@ Global
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
+ {B4688F11-33F9-41AB-846E-09CE9ECF3E08}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962}
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj
new file mode 100644
index 0000000..f31d0ee
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BTCPayServer.Plugins.BitcoinSwitch.csproj
@@ -0,0 +1,47 @@
+
+
+
+ net8.0
+ 10
+
+
+
+
+ Bitcoin Switch
+ Control harwdare using the POS as a switch
+ 1.0.0
+ true
+
+
+
+ true
+ false
+ true
+
+
+
+
+
+ StaticWebAssetsEnabled=false
+ false
+ runtime;native;build;buildTransitive;contentFiles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchCOntroller.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchCOntroller.cs
new file mode 100644
index 0000000..6d16d96
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchCOntroller.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.FileSeller;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.BitcoinSwitch;
+
+[AllowAnonymous]
+public class BitcoinSwitchController:ControllerBase
+{
+ private readonly BitcoinSwitchService _service;
+
+ public BitcoinSwitchController(BitcoinSwitchService service)
+ {
+ _service = service;
+ }
+
+ [Route("~/apps/{id}/pos/bitcoinswitch")]
+ public async Task Connect(string id)
+ {
+ if (HttpContext.WebSockets.IsWebSocketRequest)
+ {
+ using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
+
+ await Echo(id, webSocket);
+ }
+ else
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
+ }
+ }
+
+ private async Task Echo(string id, WebSocket webSocket)
+ {
+ try
+ {
+ _service.AppToSockets.Add(id, webSocket);
+ var buffer = new byte[1024 * 4];
+ var receiveResult = await webSocket.ReceiveAsync(
+ new ArraySegment(buffer), CancellationToken.None);
+
+ while (!receiveResult.CloseStatus.HasValue && webSocket.State == WebSocketState.Open)
+ {
+
+ receiveResult = await webSocket.ReceiveAsync(
+ new ArraySegment(buffer), CancellationToken.None);
+ }
+
+ await webSocket.CloseAsync(
+ receiveResult.CloseStatus.Value,
+ receiveResult.CloseStatusDescription,
+ CancellationToken.None);
+
+ }
+ finally
+ {
+ _service.AppToSockets.Remove(id, webSocket);
+ }
+ }
+
+
+
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchPlugin.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchPlugin.cs
new file mode 100644
index 0000000..82247c0
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchPlugin.cs
@@ -0,0 +1,23 @@
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Plugins.FileSeller;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.BitcoinSwitch;
+
+public class BitcoinSwitchPlugin : BaseBTCPayServerPlugin
+{
+ public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
+ {
+ new() {Identifier = nameof(BTCPayServer), Condition = ">=2.0.0"}
+ };
+
+ public override void Execute(IServiceCollection applicationBuilder)
+ {
+ applicationBuilder.AddSingleton();
+ applicationBuilder.AddHostedService(provider => provider.GetRequiredService());
+ applicationBuilder.AddUIExtension("app-template-editor-item-detail", "BitcoinSwitch/BitcoinSwitchPluginTemplateEditorItemDetail");
+
+ base.Execute(applicationBuilder);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs
new file mode 100644
index 0000000..c4b5c67
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/BitcoinSwitchService.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.WebSockets;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Events;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.Crowdfund;
+using BTCPayServer.Plugins.PointOfSale;
+using BTCPayServer.Services.Apps;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Storage.Services;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.FileSeller
+{
+
+
+
+ public class BitcoinSwitchEvent
+ {
+ public string AppId { get; set; }
+ public string Message { get; set; }
+
+ }
+
+
+ public class BitcoinSwitchService : EventHostedServiceBase
+ {
+ private readonly AppService _appService;
+ private readonly InvoiceRepository _invoiceRepository;
+ public BitcoinSwitchService(EventAggregator eventAggregator,
+ ILogger logger,
+ AppService appService,
+ InvoiceRepository invoiceRepository) : base(eventAggregator, logger)
+ {
+ _appService = appService;
+ _invoiceRepository = invoiceRepository;
+ }
+
+ public ConcurrentMultiDictionary AppToSockets { get; } = new();
+
+
+ protected override void SubscribeToEvents()
+ {
+ Subscribe();
+ Subscribe();
+ base.SubscribeToEvents();
+ }
+
+ protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
+ {
+ if (evt is BitcoinSwitchEvent bitcoinSwitchEvent)
+ {
+ if (AppToSockets.TryGetValues(bitcoinSwitchEvent.AppId, out var sockets))
+ {
+ foreach (var socket in sockets)
+ {
+ try
+ {
+ await socket.SendAsync(
+ new ArraySegment(System.Text.Encoding.UTF8.GetBytes(bitcoinSwitchEvent.Message)),
+ WebSocketMessageType.Text, true, cancellationToken);
+ }
+ catch (Exception e)
+ {
+
+ }
+ }
+ }
+ }
+
+ if (evt is not InvoiceEvent invoiceEvent) return;
+ List cartItems = null;
+ if (invoiceEvent.Name is not (InvoiceEvent.Completed or InvoiceEvent.MarkedCompleted
+ or InvoiceEvent.Confirmed))
+ {
+ return;
+ }
+
+ var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice);
+
+ if (!appIds.Any())
+ {
+ return;
+ }
+
+ if (invoiceEvent.Invoice.Metadata.AdditionalData.TryGetValue("bitcoinswitchactivated", out var activated))
+ {
+ return;
+ }
+
+ if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) ||
+ AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems)))
+ {
+ var items = cartItems ?? new List();
+ if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) &&
+ !items.Exists(cartItem => cartItem.Id == invoiceEvent.Invoice.Metadata.ItemCode))
+ {
+ items.Add(new AppCartItem()
+ {
+ Id = invoiceEvent.Invoice.Metadata.ItemCode,
+ Count = 1,
+ Price = invoiceEvent.Invoice.Price
+ });
+ }
+
+ var apps = (await _appService.GetApps(appIds)).Select(data =>
+ {
+ switch (data.AppType)
+ {
+ case PointOfSaleAppType.AppType:
+ var possettings = data.GetSettings();
+ return (Data: data, Settings: (object) possettings,
+ Items: AppService.Parse(possettings.Template));
+ case CrowdfundAppType.AppType:
+ var cfsettings = data.GetSettings();
+ return (Data: data, Settings: cfsettings,
+ Items: AppService.Parse(cfsettings.PerksTemplate));
+ default:
+ return (null, null, null);
+ }
+ }).Where(tuple => tuple.Data != null && tuple.Items.Any(item =>
+ item.AdditionalData?.ContainsKey("bitcoinswitch_gpio") is true &&
+ items.Exists(cartItem => cartItem.Id == item.Id)));
+
+
+ foreach (var valueTuple in apps)
+ {
+ foreach (var item1 in valueTuple.Items.Where(item =>
+ item.AdditionalData?.ContainsKey("bitcoinswitch_gpio") is true &&
+ items.Exists(cartItem => cartItem.Id == item.Id)))
+ {
+ var appId = valueTuple.Data.Id;
+ var gpio = item1.AdditionalData["bitcoinswitch_gpio"].Value();
+ var duration = item1.AdditionalData.TryGetValue("bitcoinswitch_duration", out var durationObj) &&
+ durationObj.Type == JTokenType.Integer
+ ? durationObj.Value()
+ : "5000";
+
+ PushEvent(new BitcoinSwitchEvent()
+ {
+ AppId = appId,
+ Message = $"{gpio}-{duration}.0"
+ });
+
+ }
+ }
+
+
+ invoiceEvent.Invoice.Metadata.SetAdditionalData("bitcoinswitchactivated", "true");
+ await _invoiceRepository.UpdateInvoiceMetadata(invoiceEvent.InvoiceId, invoiceEvent.Invoice.StoreId,
+ invoiceEvent.Invoice.Metadata.ToJObject());
+
+
+ }
+
+ await base.ProcessEvent(evt, cancellationToken);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionary.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionary.cs
new file mode 100644
index 0000000..616df29
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionary.cs
@@ -0,0 +1,146 @@
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+///
+/// https://stackoverflow.com/a/60719233/275504
+///
+///
+///
+public class ConcurrentMultiDictionary
+ : IEnumerable>
+{
+ private class Bag : HashSet
+ {
+ public bool IsDiscarded { get; set; }
+ }
+
+ private readonly ConcurrentDictionary _dictionary;
+
+ public ConcurrentMultiDictionary()
+ {
+ _dictionary = new ConcurrentDictionary();
+ }
+
+ public int Count => _dictionary.Count;
+
+ public bool Add(TKey key, TValue value)
+ {
+ var spinWait = new SpinWait();
+ while (true)
+ {
+ var bag = _dictionary.GetOrAdd(key, _ => new Bag());
+ lock (bag)
+ {
+ if (!bag.IsDiscarded) return bag.Add(value);
+ }
+ spinWait.SpinOnce();
+ }
+ }
+
+ public bool AddOrReplace(TKey key, TValue value)
+ {
+ Remove(key, value);
+ return Add(key, value);
+ }
+
+ public bool Remove(TKey key)
+ {
+ return _dictionary.TryRemove(key, out _);
+ }
+ public bool Remove(TKey key, out TValue[]? items)
+ {
+ if(_dictionary.TryRemove(key, out var x))
+ {
+ items = x.ToArray();
+ return true;
+
+ }
+ items = null;
+ return false;
+ }
+ public bool Remove(TKey key, TValue value)
+ {
+ var spinWait = new SpinWait();
+ while (true)
+ {
+ if (!_dictionary.TryGetValue(key, out var bag)) return false;
+ bool spinAndRetry = false;
+ lock (bag)
+ {
+ if (bag.IsDiscarded)
+ {
+ spinAndRetry = true;
+ }
+ else
+ {
+ bool valueRemoved = bag.Remove(value);
+ if (!valueRemoved) return false;
+ if (bag.Count != 0) return true;
+ bag.IsDiscarded = true;
+ }
+ }
+ if (spinAndRetry) { spinWait.SpinOnce(); continue; }
+ bool keyRemoved = _dictionary.TryRemove(key, out var currentBag);
+ Debug.Assert(keyRemoved, $"Key {key} was not removed");
+ Debug.Assert(bag == currentBag, $"Removed wrong bag");
+ return true;
+ }
+ }
+
+ public bool TryGetValues(TKey key, out TValue[] values)
+ {
+ if (!_dictionary.TryGetValue(key, out var bag)) { values = null; return false; }
+ bool isDiscarded;
+ lock (bag) { isDiscarded = bag.IsDiscarded; values = bag.ToArray(); }
+ if (isDiscarded) { values = null; return false; }
+ return true;
+ }
+
+ public bool Contains(TKey key, TValue value)
+ {
+ if (!_dictionary.TryGetValue(key, out var bag)) return false;
+ lock (bag) return !bag.IsDiscarded && bag.Contains(value);
+ }
+ public bool Contains(TKey key, IEnumerable value)
+ {
+ if (!_dictionary.TryGetValue(key, out var bag)) return false;
+ lock (bag) return !bag.IsDiscarded && value.Any(bag.Contains);
+ }
+
+ public bool ContainsKey(TKey key) => _dictionary.ContainsKey(key);
+
+ public ICollection Keys => _dictionary.Keys;
+
+ public IEnumerator> GetEnumerator()
+ {
+ foreach (var key in _dictionary.Keys)
+ {
+ if (this.TryGetValues(key, out var values))
+ {
+ yield return new KeyValuePair(key, values);
+ }
+ }
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public bool ContainsValue(TValue value)
+ {
+ return _dictionary.Keys.Any(key => Contains(key, value));
+ }
+ public bool ContainsValue(IEnumerable value)
+ {
+ return _dictionary.Keys.Any(key => Contains(key, value));
+ }
+ public IEnumerable GetKeysContainingValue(IEnumerable value)
+ {
+ return _dictionary.Keys.Where(key => Contains(key, value));
+ }
+ public IEnumerable GetKeysContainingValue(TValue value)
+ {
+ return _dictionary.Keys.Where(key => Contains(key, value));
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionaryExtensions.cs b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionaryExtensions.cs
new file mode 100644
index 0000000..d48da87
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/MultiValueDictionaryExtensions.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+
+namespace Relay
+{
+ public static class MultiValueDictionaryExtensions
+ {
+ public static ConcurrentMultiDictionary ToMultiValueDictionary(this IEnumerable collection, Func keySelector, Func valueSelector)
+ {
+ var dictionary = new ConcurrentMultiDictionary();
+ foreach (var item in collection)
+ {
+ dictionary.Add(keySelector(item), valueSelector(item));
+ }
+ return dictionary;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/README.md b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/README.md
new file mode 100644
index 0000000..5e78b44
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/README.md
@@ -0,0 +1,17 @@
+# Biotcoin Switch Plugin
+
+This plugin allows you to connect your BTCPay Server to the Bitcoin Switch hardware, developed by the amazing LNURL team.
+
+## Installation
+
+1. Go to the "Plugins" page of your BTCPay Server
+2. Click on install under the "Bitcoin Switch" plugin listing
+3. Restart BTCPay Server
+4. Go to your point of sale or crowdfund app settings
+5. Click on "Edit" on the item/perk you'd like to enable Bitcoin Switch for.
+6. Specify the hardware GPIO pin and duration of activation
+7. Close the editor
+8. Choose Print Display in Point of Sale style (if you want to be able to print LNURL QR codes for each item specifically).
+9. Save the app
+10. Your websocket url is the point of sale url, appended with "/bitcoinswitch" and the scheme set to wss:// (e.g. wss://mybtcpay.com/apps/A9xD2nxuWzQTh33E9U6YvyyXrvA/pos/bitcoinswitch)
+11. Upon purchase (invoice marked as settled), any open websockets will receive the message to activate (io-duration)
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/Views/Shared/BitcoinSwitch/BitcoinSwitchPluginTemplateEditorItemDetail.cshtml b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/Views/Shared/BitcoinSwitch/BitcoinSwitchPluginTemplateEditorItemDetail.cshtml
new file mode 100644
index 0000000..c99e29f
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/Views/Shared/BitcoinSwitch/BitcoinSwitchPluginTemplateEditorItemDetail.cshtml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Plugins/BTCPayServer.Plugins.BitcoinSwitch/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/_ViewImports.cshtml
new file mode 100644
index 0000000..d897d63
--- /dev/null
+++ b/Plugins/BTCPayServer.Plugins.BitcoinSwitch/_ViewImports.cshtml
@@ -0,0 +1,5 @@
+@using BTCPayServer.Abstractions.Services
+@inject Safe Safe
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+@addTagHelper *, BTCPayServer
+@addTagHelper *, BTCPayServer.Abstractions
\ No newline at end of file