add bitcoin switch plugin

This commit is contained in:
Andrew Camilleri (Kukks)
2025-07-01 21:55:57 +02:00
parent 597ef2110d
commit 77a837a436
11 changed files with 510 additions and 0 deletions

View File

@@ -12,6 +12,7 @@
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build Solution" enabled="true" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="ConfigBuilder" run_configuration_type="DotNetProject" />
</method>
</configuration>
</component>

View File

@@ -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}

View File

@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>10</LangVersion>
</PropertyGroup>
<!-- -->
<!-- Plugin specific properties -->
<PropertyGroup>
<Product>Bitcoin Switch</Product>
<Description>Control harwdare using the POS as a switch</Description>
<Version>1.0.0</Version>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<!-- Plugin development properties -->
<PropertyGroup>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PreserveCompilationContext>false</PreserveCompilationContext>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
<ItemDefinitionGroup>
<ProjectReference>
<Properties>StaticWebAssetsEnabled=false</Properties>
<Private>false</Private>
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
</ProjectReference>
</ItemDefinitionGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\**" />
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\js\" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Views\Shared\BitcoinSwitch\FileSellerTemplateEditorItemDetail.cshtml" />
</ItemGroup>
</Project>

View File

@@ -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<byte>(buffer), CancellationToken.None);
while (!receiveResult.CloseStatus.HasValue && webSocket.State == WebSocketState.Open)
{
receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
}
await webSocket.CloseAsync(
receiveResult.CloseStatus.Value,
receiveResult.CloseStatusDescription,
CancellationToken.None);
}
finally
{
_service.AppToSockets.Remove(id, webSocket);
}
}
}

View File

@@ -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<BitcoinSwitchService>();
applicationBuilder.AddHostedService<BitcoinSwitchService>(provider => provider.GetRequiredService<BitcoinSwitchService>());
applicationBuilder.AddUIExtension("app-template-editor-item-detail", "BitcoinSwitch/BitcoinSwitchPluginTemplateEditorItemDetail");
base.Execute(applicationBuilder);
}
}

View File

@@ -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<BitcoinSwitchService> logger,
AppService appService,
InvoiceRepository invoiceRepository) : base(eventAggregator, logger)
{
_appService = appService;
_invoiceRepository = invoiceRepository;
}
public ConcurrentMultiDictionary<string, WebSocket> AppToSockets { get; } = new();
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<BitcoinSwitchEvent>();
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<byte>(System.Text.Encoding.UTF8.GetBytes(bitcoinSwitchEvent.Message)),
WebSocketMessageType.Text, true, cancellationToken);
}
catch (Exception e)
{
}
}
}
}
if (evt is not InvoiceEvent invoiceEvent) return;
List<AppCartItem> 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<AppCartItem>();
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<PointOfSaleSettings>();
return (Data: data, Settings: (object) possettings,
Items: AppService.Parse(possettings.Template));
case CrowdfundAppType.AppType:
var cfsettings = data.GetSettings<CrowdfundSettings>();
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<string>();
var duration = item1.AdditionalData.TryGetValue("bitcoinswitch_duration", out var durationObj) &&
durationObj.Type == JTokenType.Integer
? durationObj.Value<string>()
: "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);
}
}
}

View File

@@ -0,0 +1,146 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
/// <summary>
/// https://stackoverflow.com/a/60719233/275504
/// </summary>
/// <typeparam name="TKey"></typeparam>
/// <typeparam name="TValue"></typeparam>
public class ConcurrentMultiDictionary<TKey, TValue>
: IEnumerable<KeyValuePair<TKey, TValue[]>>
{
private class Bag : HashSet<TValue>
{
public bool IsDiscarded { get; set; }
}
private readonly ConcurrentDictionary<TKey, Bag> _dictionary;
public ConcurrentMultiDictionary()
{
_dictionary = new ConcurrentDictionary<TKey, Bag>();
}
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<TValue> 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<TKey> Keys => _dictionary.Keys;
public IEnumerator<KeyValuePair<TKey, TValue[]>> GetEnumerator()
{
foreach (var key in _dictionary.Keys)
{
if (this.TryGetValues(key, out var values))
{
yield return new KeyValuePair<TKey, TValue[]>(key, values);
}
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public bool ContainsValue(TValue value)
{
return _dictionary.Keys.Any(key => Contains(key, value));
}
public bool ContainsValue(IEnumerable<TValue> value)
{
return _dictionary.Keys.Any(key => Contains(key, value));
}
public IEnumerable<TKey> GetKeysContainingValue(IEnumerable<TValue> value)
{
return _dictionary.Keys.Where(key => Contains(key, value));
}
public IEnumerable<TKey> GetKeysContainingValue(TValue value)
{
return _dictionary.Keys.Where(key => Contains(key, value));
}
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace Relay
{
public static class MultiValueDictionaryExtensions
{
public static ConcurrentMultiDictionary<TKey, TValue> ToMultiValueDictionary<TInput, TKey, TValue>(this IEnumerable<TInput> collection, Func<TInput, TKey> keySelector, Func<TInput, TValue> valueSelector)
{
var dictionary = new ConcurrentMultiDictionary<TKey, TValue>();
foreach (var item in collection)
{
dictionary.Add(keySelector(item), valueSelector(item));
}
return dictionary;
}
}
}

View File

@@ -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)

View File

@@ -0,0 +1,12 @@

<template v-if="editingItem">
<div class="form-group">
<label class="form-label">Bitcoin Switch GPIO</label>
<input type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" :value="editingItem['bitcoinswitch_gpio'] || ''" v-on:change="if(event.target.value) Vue.set(editingItem, 'bitcoinswitch_gpio', event.target.value); else Vue.delete(editingItem, 'bitcoinswitch_gpio');"/>
</div>
<div class="form-group" v-if="editingItem['bitcoinswitch_gpio']">
<label class="form-label">Bitcoin Switch Duration</label>
<input type="number" inputmode="numeric" min="1" step="1000" class="form-control mb-2" :value="editingItem['bitcoinswitch_duration'] || ''" asp-items="files" class="form-select w-auto" v-on:change="if(event.target.value) Vue.set(editingItem, 'bitcoinswitch_duration', event.target.value); else Vue.delete(editingItem, 'bitcoinswitch_duration');"/>
</div>
</template>

View File

@@ -0,0 +1,5 @@
@using BTCPayServer.Abstractions.Services
@inject Safe Safe
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer
@addTagHelper *, BTCPayServer.Abstractions