Details
+ @if (_spool.SpoolmanId > 0)
+ {
+
+ @L["spoolman.id"]
+ @_spool.SpoolmanId
+
+ }
}
+@if (_spool != null)
+{
+
+}
+
@code {
[Parameter] public Guid Id { get; set; }
private bool _loading = true;
private SpoolDto? _spool;
+ private FindSpoolModal? _findModal;
private bool _showRemainingForm;
private decimal _remainingGrams;
private decimal _remainingPct;
@@ -241,6 +264,7 @@ else
private string? _nfcMessage;
private bool _nfcSuccess;
private string? _tagJson;
+ private int? _tagSpoolmanId;
private Timer? _clockTimer;
[JSInvokable] public void OnWriteSuccess() { _nfcMessage = L["tag.write.success"]; _nfcSuccess = true; _writingNfc = false; StateHasChanged(); }
@@ -274,6 +298,12 @@ else
private async Task MarkReopenedAsync() { await Spools.MarkReopenedAsync(Id); _spool = await Spools.GetByIdAsync(Id); }
private async Task MarkConsumedAsync() { await Spools.MarkConsumedAsync(Id); _spool = await Spools.GetByIdAsync(Id); }
+ private async Task FindSpoolAsync()
+ {
+ if (_findModal != null)
+ await _findModal.OpenAsync();
+ }
+
private async Task WriteNfcAsync()
{
_writingNfc = true;
@@ -288,7 +318,8 @@ else
return;
}
_tagJson = encoded.JsonPayload;
- await Nfc.WriteAsync(encoded.JsonPayload, DotNetObjectReference.Create(this));
+ _tagSpoolmanId = encoded.SpoolmanId;
+ await Nfc.WriteAsync(encoded.JsonPayload, DotNetObjectReference.Create(this), encoded.SpoolmanId);
}
private static string GetBarClass(decimal p) => p >= 50 ? "high" : p >= 20 ? "medium" : "low";
diff --git a/src/SpoolManager.Client/Pages/Spools/SpoolList.razor b/src/SpoolManager.Client/Pages/Spools/SpoolList.razor
index d6e34e6..1f321e3 100644
--- a/src/SpoolManager.Client/Pages/Spools/SpoolList.razor
+++ b/src/SpoolManager.Client/Pages/Spools/SpoolList.razor
@@ -135,6 +135,13 @@ else
disabled="@(_nfcWriting && _nfcTarget?.Id == spool.Id)">
+ @if (!string.IsNullOrWhiteSpace(spool.RfidTagUid))
+ {
+
+ }
}
}
+@if (_findTarget != null)
+{
+
+}
+
@code {
private bool _loading = true;
private List
_spools = [];
@@ -179,6 +191,9 @@ else
private bool _nfcSuccess;
private string? _nfcMessage;
+ private SpoolDto? _findTarget;
+ private FindSpoolModal? _findModal;
+
[JSInvokable]
public void OnWriteSuccess()
{
@@ -273,7 +288,16 @@ else
return;
}
- await Nfc.WriteAsync(encoded.JsonPayload, DotNetObjectReference.Create(this));
+ await Nfc.WriteAsync(encoded.JsonPayload, DotNetObjectReference.Create(this), encoded.SpoolmanId);
+ }
+
+ private async Task FindSpoolAsync(SpoolDto spool)
+ {
+ _findTarget = spool;
+ StateHasChanged();
+ await Task.Yield();
+ if (_findModal != null)
+ await _findModal.OpenAsync();
}
private static string GetBarClass(decimal p) => p >= 50 ? "high" : p >= 20 ? "medium" : "low";
diff --git a/src/SpoolManager.Client/Program.cs b/src/SpoolManager.Client/Program.cs
index ed7e0f8..cae30d8 100644
--- a/src/SpoolManager.Client/Program.cs
+++ b/src/SpoolManager.Client/Program.cs
@@ -27,5 +27,6 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
await builder.Build().RunAsync();
diff --git a/src/SpoolManager.Client/Services/NfcService.cs b/src/SpoolManager.Client/Services/NfcService.cs
index 501dd66..0ef2eb3 100644
--- a/src/SpoolManager.Client/Services/NfcService.cs
+++ b/src/SpoolManager.Client/Services/NfcService.cs
@@ -17,9 +17,9 @@ public async Task CheckSupportAsync()
return IsSupported;
}
- public async Task WriteAsync(string jsonPayload, DotNetObjectReference dotnetRef) where T : class
+ public async Task WriteAsync(string jsonPayload, DotNetObjectReference dotnetRef, int? spoolmanId = null) where T : class
{
- await _js.InvokeVoidAsync("nfcHelper.write", jsonPayload, dotnetRef);
+ await _js.InvokeVoidAsync("nfcHelper.write", jsonPayload, dotnetRef, spoolmanId);
}
public async Task StartReadAsync(DotNetObjectReference dotnetRef) where T : class
diff --git a/src/SpoolManager.Client/Services/SpoolmanApiKeyService.cs b/src/SpoolManager.Client/Services/SpoolmanApiKeyService.cs
new file mode 100644
index 0000000..058b2cb
--- /dev/null
+++ b/src/SpoolManager.Client/Services/SpoolmanApiKeyService.cs
@@ -0,0 +1,26 @@
+using System.Net.Http.Json;
+using SpoolManager.Shared.DTOs.Spoolman;
+
+namespace SpoolManager.Client.Services;
+
+public class SpoolmanApiKeyService
+{
+ private readonly HttpClient _http;
+ public SpoolmanApiKeyService(HttpClient http) => _http = http;
+
+ public Task?> GetAllAsync() =>
+ _http.GetFromJsonAsync>("api/spoolman/apikeys");
+
+ public async Task CreateAsync(string name)
+ {
+ var resp = await _http.PostAsJsonAsync("api/spoolman/apikeys", new CreateSpoolmanApiKeyRequest { Name = name });
+ if (!resp.IsSuccessStatusCode) return null;
+ return await resp.Content.ReadFromJsonAsync();
+ }
+
+ public Task DeleteAsync(Guid id) =>
+ _http.DeleteAsync($"api/spoolman/apikeys/{id}");
+
+ public Task?> GetLogsAsync(Guid apiKeyId) =>
+ _http.GetFromJsonAsync>($"api/spoolman/apikeys/{apiKeyId}/logs");
+}
diff --git a/src/SpoolManager.Client/_Imports.razor b/src/SpoolManager.Client/_Imports.razor
index a7b836c..f090cf9 100644
--- a/src/SpoolManager.Client/_Imports.razor
+++ b/src/SpoolManager.Client/_Imports.razor
@@ -20,3 +20,4 @@
@using SpoolManager.Shared.DTOs.Dryers
@using SpoolManager.Shared.DTOs.Admin
@using SpoolManager.Shared.DTOs.Tickets
+@using SpoolManager.Shared.DTOs.Spoolman
diff --git a/src/SpoolManager.Client/wwwroot/css/app.css b/src/SpoolManager.Client/wwwroot/css/app.css
index d261117..fdeacc8 100644
--- a/src/SpoolManager.Client/wwwroot/css/app.css
+++ b/src/SpoolManager.Client/wwwroot/css/app.css
@@ -351,10 +351,25 @@
white-space: normal;
}
+/* Find Spool pulse animation */
+@keyframes nfc-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.1); }
+}
+
+.nfc-pulse-icon {
+ animation: nfc-pulse 1.4s ease-in-out infinite;
+ display: inline-block;
+}
+
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
+
+ .nfc-pulse-icon {
+ animation: none;
+ }
}
diff --git a/src/SpoolManager.Client/wwwroot/i18n/de.json b/src/SpoolManager.Client/wwwroot/i18n/de.json
index f7a5edd..e514422 100644
--- a/src/SpoolManager.Client/wwwroot/i18n/de.json
+++ b/src/SpoolManager.Client/wwwroot/i18n/de.json
@@ -452,5 +452,25 @@
"auth.terms.accept": "Ich akzeptiere die",
"auth.privacy.accept": "Ich habe die",
- "auth.terms.required": "Bitte akzeptiere die Nutzungsbedingungen und die Datenschutzerklärung."
+ "auth.terms.required": "Bitte akzeptiere die Nutzungsbedingungen und die Datenschutzerklärung.",
+
+ "spool.find.title": "Spule suchen",
+ "spool.find.scanning": "Handy an den NFC-Tag der Spule halten...",
+ "spool.find.found": "Richtige Spule gefunden!",
+ "spool.find.wrong": "Falsche Spule",
+ "spool.find.retry": "Nächsten Tag versuchen...",
+ "spool.find.no.tag": "Diese Spule hat keinen NFC-Tag. Zuerst einen Tag schreiben.",
+
+ "spoolman.title": "Klipper / Spoolman Integration",
+ "spoolman.hint": "SpoolHero stellt eine Spoolman-kompatible API bereit. Füge folgendes in deine moonraker.conf ein, um automatisches Filament-Tracking zu aktivieren:",
+ "spoolman.apikeys": "API-Schlüssel",
+ "spoolman.apikeys.create": "API-Schlüssel erstellen",
+ "spoolman.apikeys.name": "Name",
+ "spoolman.apikeys.lastused": "Zuletzt benutzt",
+ "spoolman.apikeys.empty": "Noch keine API-Schlüssel. Erstelle einen, um Moonraker zu verbinden.",
+ "spoolman.apikeys.url": "Server-URL",
+ "spoolman.apikeys.logs": "API-Calls (letzte 24h)",
+ "spoolman.apikeys.logs.empty": "Keine Calls in den letzten 24 Stunden.",
+ "spoolman.id": "Spoolman-ID",
+ "nav.settings.spoolman": "Klipper-Integration"
}
diff --git a/src/SpoolManager.Client/wwwroot/i18n/en.json b/src/SpoolManager.Client/wwwroot/i18n/en.json
index 3cdeef9..70bca0c 100644
--- a/src/SpoolManager.Client/wwwroot/i18n/en.json
+++ b/src/SpoolManager.Client/wwwroot/i18n/en.json
@@ -452,5 +452,26 @@
"auth.terms.accept": "I accept the",
"auth.privacy.accept": "I have read and agree to the",
- "auth.terms.required": "Please accept the Terms of Use and Privacy Policy."
+ "auth.terms.required": "Please accept the Terms of Use and Privacy Policy.",
+
+
+ "spool.find.title": "Find Spool",
+ "spool.find.scanning": "Hold your phone near the spool's NFC tag...",
+ "spool.find.found": "Correct spool found!",
+ "spool.find.wrong": "Wrong spool",
+ "spool.find.retry": "Try again with another tag...",
+ "spool.find.no.tag": "This spool has no NFC tag assigned. Write a tag first.",
+
+ "spoolman.title": "Klipper / Spoolman Integration",
+ "spoolman.hint": "SpoolHero provides a Spoolman-compatible API. Add the following to your moonraker.conf to enable automatic filament tracking:",
+ "spoolman.apikeys": "API Keys",
+ "spoolman.apikeys.create": "Create API Key",
+ "spoolman.apikeys.name": "Name",
+ "spoolman.apikeys.lastused": "Last Used",
+ "spoolman.apikeys.empty": "No API keys yet. Create one to connect Moonraker.",
+ "spoolman.apikeys.url": "Server URL",
+ "spoolman.apikeys.logs": "API Calls (last 24h)",
+ "spoolman.apikeys.logs.empty": "No calls in the last 24 hours.",
+ "spoolman.id": "Spoolman ID",
+ "nav.settings.spoolman": "Klipper Integration"
}
diff --git a/src/SpoolManager.Client/wwwroot/js/nfc.js b/src/SpoolManager.Client/wwwroot/js/nfc.js
index 32b64d6..14159e6 100644
--- a/src/SpoolManager.Client/wwwroot/js/nfc.js
+++ b/src/SpoolManager.Client/wwwroot/js/nfc.js
@@ -9,16 +9,18 @@ window.nfcHelper = {
if (_readAbort) { _readAbort.abort(); _readAbort = null; }
},
- write: async (jsonPayload, dotnetRef) => {
+ write: async (jsonPayload, dotnetRef, spoolmanId) => {
try {
const ndef = new NDEFReader();
- await ndef.write({
- records: [{
- recordType: "mime",
- mediaType: "application/json",
- data: new TextEncoder().encode(jsonPayload)
- }]
- });
+ const records = [{
+ recordType: "mime",
+ mediaType: "application/json",
+ data: new TextEncoder().encode(jsonPayload)
+ }];
+ if (spoolmanId != null) {
+ records.push({ recordType: "text", data: `SPOOL:${spoolmanId}\nFILAMENT:${spoolmanId}\n` });
+ }
+ await ndef.write({ records });
await dotnetRef.invokeMethodAsync('OnWriteSuccess');
} catch (e) {
await dotnetRef.invokeMethodAsync('OnWriteError', e.message);
diff --git a/src/SpoolManager.Infrastructure/Data/Mappings/SpoolManagerMappings.cs b/src/SpoolManager.Infrastructure/Data/Mappings/SpoolManagerMappings.cs
index 58346a4..de0d0f2 100644
--- a/src/SpoolManager.Infrastructure/Data/Mappings/SpoolManagerMappings.cs
+++ b/src/SpoolManager.Infrastructure/Data/Mappings/SpoolManagerMappings.cs
@@ -139,6 +139,7 @@ public static MappingSchema Build()
.HasTableName("spools")
.HasPrimaryKey(x => x.Id)
.Property(x => x.Id).HasColumnName("id")
+ .Property(x => x.SpoolmanId).HasColumnName("spoolman_id")
.Property(x => x.ProjectId).HasColumnName("project_id")
.Property(x => x.FilamentMaterialId).HasColumnName("filament_material_id")
.Property(x => x.RfidTagUid).HasColumnName("rfid_tag_uid")
@@ -228,6 +229,26 @@ public static MappingSchema Build()
.Property(x => x.IsEnabled).HasColumnName("is_enabled")
.Property(x => x.BaseUrl).HasColumnName("base_url");
+ builder.Entity()
+ .HasTableName("spoolman_api_keys")
+ .HasPrimaryKey(x => x.Id)
+ .Property(x => x.Id).HasColumnName("id")
+ .Property(x => x.ProjectId).HasColumnName("project_id")
+ .Property(x => x.ApiKey).HasColumnName("api_key")
+ .Property(x => x.Name).HasColumnName("name")
+ .Property(x => x.CreatedAt).HasColumnName("created_at")
+ .Property(x => x.LastUsedAt).HasColumnName("last_used_at");
+
+ builder.Entity()
+ .HasTableName("spoolman_call_logs")
+ .HasPrimaryKey(x => x.Id)
+ .Property(x => x.Id).HasColumnName("id")
+ .Property(x => x.ApiKeyId).HasColumnName("api_key_id")
+ .Property(x => x.CalledAt).HasColumnName("called_at")
+ .Property(x => x.Method).HasColumnName("method")
+ .Property(x => x.Path).HasColumnName("path")
+ .Property(x => x.StatusCode).HasColumnName("status_code");
+
builder.Build();
return schema;
}
diff --git a/src/SpoolManager.Infrastructure/Data/SpoolManagerDb.cs b/src/SpoolManager.Infrastructure/Data/SpoolManagerDb.cs
index 1b3a5d9..be40ad7 100644
--- a/src/SpoolManager.Infrastructure/Data/SpoolManagerDb.cs
+++ b/src/SpoolManager.Infrastructure/Data/SpoolManagerDb.cs
@@ -22,4 +22,6 @@ public SpoolManagerDb(DataOptions options) : base(options.Option
public ITable TicketComments => this.GetTable();
public ITable SmtpSettings => this.GetTable();
public ITable SiteSettings => this.GetTable();
+ public ITable SpoolmanApiKeys => this.GetTable();
+ public ITable SpoolmanCallLogs => this.GetTable();
}
diff --git a/src/SpoolManager.Infrastructure/Migrations/M011_SpoolmanCompat.cs b/src/SpoolManager.Infrastructure/Migrations/M011_SpoolmanCompat.cs
new file mode 100644
index 0000000..54ace20
--- /dev/null
+++ b/src/SpoolManager.Infrastructure/Migrations/M011_SpoolmanCompat.cs
@@ -0,0 +1,26 @@
+using FluentMigrator;
+
+namespace SpoolManager.Infrastructure.Migrations;
+
+[Migration(11)]
+public class M011_SpoolmanCompat : Migration
+{
+ public override void Up()
+ {
+ Execute.Sql("ALTER TABLE spools ADD COLUMN spoolman_id INT NOT NULL AUTO_INCREMENT UNIQUE");
+
+ Create.Table("spoolman_api_keys")
+ .WithColumn("id").AsString(36).PrimaryKey()
+ .WithColumn("project_id").AsString(36).NotNullable().ForeignKey("projects", "id").OnDelete(System.Data.Rule.Cascade)
+ .WithColumn("api_key").AsString(64).NotNullable().Unique()
+ .WithColumn("name").AsString(100).NotNullable()
+ .WithColumn("created_at").AsDateTime().NotNullable()
+ .WithColumn("last_used_at").AsDateTime().Nullable();
+ }
+
+ public override void Down()
+ {
+ Delete.Table("spoolman_api_keys");
+ Execute.Sql("ALTER TABLE spools DROP COLUMN spoolman_id");
+ }
+}
diff --git a/src/SpoolManager.Infrastructure/Migrations/M012_SpoolmanCallLogs.cs b/src/SpoolManager.Infrastructure/Migrations/M012_SpoolmanCallLogs.cs
new file mode 100644
index 0000000..9e17b8e
--- /dev/null
+++ b/src/SpoolManager.Infrastructure/Migrations/M012_SpoolmanCallLogs.cs
@@ -0,0 +1,29 @@
+using FluentMigrator;
+
+namespace SpoolManager.Infrastructure.Migrations;
+
+[Migration(12)]
+public class M012_SpoolmanCallLogs : Migration
+{
+ public override void Up()
+ {
+ Create.Table("spoolman_call_logs")
+ .WithColumn("id").AsString(36).PrimaryKey()
+ .WithColumn("api_key_id").AsString(36).NotNullable()
+ .ForeignKey("fk_call_logs_apikey", "spoolman_api_keys", "id").OnDelete(System.Data.Rule.Cascade)
+ .WithColumn("called_at").AsDateTime().NotNullable()
+ .WithColumn("method").AsString(10).NotNullable()
+ .WithColumn("path").AsString(255).NotNullable()
+ .WithColumn("status_code").AsInt32().NotNullable();
+
+ Create.Index("idx_spoolman_call_logs")
+ .OnTable("spoolman_call_logs")
+ .OnColumn("api_key_id").Ascending()
+ .OnColumn("called_at").Ascending();
+ }
+
+ public override void Down()
+ {
+ Delete.Table("spoolman_call_logs");
+ }
+}
diff --git a/src/SpoolManager.Infrastructure/Repositories/SpoolRepository.cs b/src/SpoolManager.Infrastructure/Repositories/SpoolRepository.cs
index fa59ae5..ca49861 100644
--- a/src/SpoolManager.Infrastructure/Repositories/SpoolRepository.cs
+++ b/src/SpoolManager.Infrastructure/Repositories/SpoolRepository.cs
@@ -14,6 +14,9 @@ public interface ISpoolRepository
Task DeleteAsync(Guid id);
Task GetTotalCountAsync();
Task> GetSpoolsNeedingLowNotificationAsync();
+ Task GetBySpoolmanIdAsync(int spoolmanId, Guid projectId);
+ Task> GetAllByProjectAsync(Guid projectId);
+ Task UpdateRemainingWeightAtomicAsync(Guid spoolId, decimal subtractGrams, decimal totalWeightGrams);
}
public class SpoolRepository : ISpoolRepository
@@ -131,6 +134,37 @@ public async Task DeleteAsync(Guid id) =>
public async Task GetTotalCountAsync() =>
await _db.Spools.CountAsync();
+ public async Task GetBySpoolmanIdAsync(int spoolmanId, Guid projectId)
+ {
+ var spool = await _db.Spools.FirstOrDefaultAsync(s => s.SpoolmanId == spoolmanId && s.ProjectId == projectId);
+ return await LoadNavPropsAsync(spool);
+ }
+
+ public async Task> GetAllByProjectAsync(Guid projectId)
+ {
+ var spools = await _db.Spools
+ .Where(s => s.ProjectId == projectId)
+ .OrderByDescending(s => s.CreatedAt)
+ .ToListAsync();
+
+ foreach (var spool in spools)
+ spool.FilamentMaterial = await _db.FilamentMaterials.FirstOrDefaultAsync(m => m.Id == spool.FilamentMaterialId);
+
+ return spools;
+ }
+
+ public async Task UpdateRemainingWeightAtomicAsync(Guid spoolId, decimal subtractGrams, decimal totalWeightGrams)
+ {
+ await _db.Spools
+ .Where(s => s.Id == spoolId)
+ .Set(s => s.RemainingWeightGrams, s => s.RemainingWeightGrams - subtractGrams < 0 ? 0 : s.RemainingWeightGrams - subtractGrams)
+ .Set(s => s.RemainingPercent, s => totalWeightGrams > 0
+ ? (s.RemainingWeightGrams - subtractGrams < 0 ? 0 : (s.RemainingWeightGrams - subtractGrams) / totalWeightGrams * 100)
+ : 0)
+ .Set(s => s.UpdatedAt, DateTime.UtcNow)
+ .UpdateAsync();
+ }
+
public async Task> GetSpoolsNeedingLowNotificationAsync()
{
var spools = await _db.Spools
diff --git a/src/SpoolManager.Infrastructure/Repositories/SpoolmanApiKeyRepository.cs b/src/SpoolManager.Infrastructure/Repositories/SpoolmanApiKeyRepository.cs
new file mode 100644
index 0000000..842ae35
--- /dev/null
+++ b/src/SpoolManager.Infrastructure/Repositories/SpoolmanApiKeyRepository.cs
@@ -0,0 +1,47 @@
+using LinqToDB;
+using SpoolManager.Infrastructure.Data;
+using SpoolManager.Shared.Models;
+
+namespace SpoolManager.Infrastructure.Repositories;
+
+public interface ISpoolmanApiKeyRepository
+{
+ Task GetByApiKeyAsync(string apiKey);
+ Task> GetAllByProjectAsync(Guid projectId);
+ Task CreateAsync(SpoolmanApiKey key);
+ Task DeleteAsync(Guid id);
+ Task UpdateLastUsedAsync(Guid id);
+}
+
+public class SpoolmanApiKeyRepository : ISpoolmanApiKeyRepository
+{
+ private readonly SpoolManagerDb _db;
+
+ public SpoolmanApiKeyRepository(SpoolManagerDb db) => _db = db;
+
+ public async Task GetByApiKeyAsync(string apiKey) =>
+ await _db.SpoolmanApiKeys.FirstOrDefaultAsync(k => k.ApiKey == apiKey);
+
+ public async Task> GetAllByProjectAsync(Guid projectId) =>
+ await _db.SpoolmanApiKeys
+ .Where(k => k.ProjectId == projectId)
+ .OrderByDescending(k => k.CreatedAt)
+ .ToListAsync();
+
+ public async Task CreateAsync(SpoolmanApiKey key)
+ {
+ key.Id = Guid.NewGuid();
+ key.CreatedAt = DateTime.UtcNow;
+ await _db.InsertAsync(key);
+ return key.Id;
+ }
+
+ public async Task DeleteAsync(Guid id) =>
+ await _db.SpoolmanApiKeys.Where(k => k.Id == id).DeleteAsync();
+
+ public async Task UpdateLastUsedAsync(Guid id) =>
+ await _db.SpoolmanApiKeys
+ .Where(k => k.Id == id)
+ .Set(k => k.LastUsedAt, DateTime.UtcNow)
+ .UpdateAsync();
+}
diff --git a/src/SpoolManager.Infrastructure/Repositories/SpoolmanCallLogRepository.cs b/src/SpoolManager.Infrastructure/Repositories/SpoolmanCallLogRepository.cs
new file mode 100644
index 0000000..40b092a
--- /dev/null
+++ b/src/SpoolManager.Infrastructure/Repositories/SpoolmanCallLogRepository.cs
@@ -0,0 +1,40 @@
+using LinqToDB;
+using SpoolManager.Infrastructure.Data;
+using SpoolManager.Shared.Models;
+
+namespace SpoolManager.Infrastructure.Repositories;
+
+public interface ISpoolmanCallLogRepository
+{
+ Task LogAsync(Guid apiKeyId, string method, string path, int statusCode);
+ Task> GetLast24hAsync(Guid apiKeyId);
+}
+
+public class SpoolmanCallLogRepository : ISpoolmanCallLogRepository
+{
+ private readonly SpoolManagerDb _db;
+
+ public SpoolmanCallLogRepository(SpoolManagerDb db) => _db = db;
+
+ public async Task LogAsync(Guid apiKeyId, string method, string path, int statusCode)
+ {
+ await _db.InsertAsync(new SpoolmanCallLog
+ {
+ Id = Guid.NewGuid(),
+ ApiKeyId = apiKeyId,
+ CalledAt = DateTime.UtcNow,
+ Method = method,
+ Path = path,
+ StatusCode = statusCode,
+ });
+ }
+
+ public async Task> GetLast24hAsync(Guid apiKeyId)
+ {
+ var cutoff = DateTime.UtcNow.AddHours(-24);
+ return await _db.SpoolmanCallLogs
+ .Where(l => l.ApiKeyId == apiKeyId && l.CalledAt >= cutoff)
+ .OrderByDescending(l => l.CalledAt)
+ .ToListAsync();
+ }
+}
diff --git a/src/SpoolManager.Infrastructure/Services/OpenSpoolService.cs b/src/SpoolManager.Infrastructure/Services/OpenSpoolService.cs
index f1c19c6..d6661dd 100644
--- a/src/SpoolManager.Infrastructure/Services/OpenSpoolService.cs
+++ b/src/SpoolManager.Infrastructure/Services/OpenSpoolService.cs
@@ -7,10 +7,10 @@ namespace SpoolManager.Infrastructure.Services;
public interface IOpenSpoolService
{
- byte[] Encode(FilamentMaterial material, Guid? spoolId = null);
+ byte[] Encode(FilamentMaterial material, Guid? spoolId = null, int? spoolmanId = null);
byte[] EncodeEntityTag(string entityType, Guid entityId);
(FilamentMaterial material, bool isValid) Decode(byte[] ndefBytes);
- string ToJson(FilamentMaterial material, Guid? spoolId = null);
+ string ToJson(FilamentMaterial material, Guid? spoolId = null, int? spoolmanId = null);
(FilamentMaterial? material, bool isValid, string rawJson, Guid? spoolId) FromJson(string json);
}
@@ -23,7 +23,7 @@ public class OpenSpoolService : IOpenSpoolService
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
- public string ToJson(FilamentMaterial material, Guid? spoolId = null)
+ public string ToJson(FilamentMaterial material, Guid? spoolId = null, int? spoolmanId = null)
{
var payload = new OpenSpoolPayload
{
@@ -34,7 +34,8 @@ public string ToJson(FilamentMaterial material, Guid? spoolId = null)
Brand = material.Brand,
MinTemp = material.MinTempCelsius,
MaxTemp = material.MaxTempCelsius,
- SmSpoolId = spoolId?.ToString()
+ SmSpoolId = spoolId?.ToString(),
+ SpoolmanId = spoolmanId > 0 ? spoolmanId : null,
};
return JsonSerializer.Serialize(payload, _jsonOptions);
}
@@ -107,12 +108,37 @@ private static bool TryParseHex(string hex, out int r, out int g, out int b)
}
}
- public byte[] Encode(FilamentMaterial material, Guid? spoolId = null)
+ public byte[] Encode(FilamentMaterial material, Guid? spoolId = null, int? spoolmanId = null)
{
- var json = ToJson(material, spoolId);
+ var json = ToJson(material, spoolId, spoolmanId);
var jsonBytes = Encoding.UTF8.GetBytes(json);
var typeBytes = Encoding.UTF8.GetBytes("application/json");
- return BuildNdefMessage(typeBytes, jsonBytes);
+
+ if (spoolmanId.HasValue)
+ {
+ var nfc2klipperText = $"SPOOL:{spoolmanId.Value}\nFILAMENT:{spoolmanId.Value}\n";
+ var textType = Encoding.UTF8.GetBytes("T");
+ var firstRecord = BuildNdefRecord(typeBytes, jsonBytes, isFirst: true, isLast: false);
+ var lastRecord = BuildNdefRecord(textType, BuildTextPayload(nfc2klipperText), isFirst: false, isLast: true, tnf: 0x01);
+
+ using var ms = new MemoryStream();
+ ms.Write(firstRecord, 0, firstRecord.Length);
+ ms.Write(lastRecord, 0, lastRecord.Length);
+ return ms.ToArray();
+ }
+
+ return BuildNdefRecord(typeBytes, jsonBytes, isFirst: true, isLast: true);
+ }
+
+ private static byte[] BuildTextPayload(string text)
+ {
+ var textBytes = Encoding.UTF8.GetBytes(text);
+ var langCode = Encoding.ASCII.GetBytes("en");
+ var payload = new byte[1 + langCode.Length + textBytes.Length];
+ payload[0] = (byte)langCode.Length;
+ Array.Copy(langCode, 0, payload, 1, langCode.Length);
+ Array.Copy(textBytes, 0, payload, 1 + langCode.Length, textBytes.Length);
+ return payload;
}
public byte[] EncodeEntityTag(string entityType, Guid entityId)
@@ -141,11 +167,19 @@ public byte[] EncodeEntityTag(string entityType, Guid entityId)
}
private static byte[] BuildNdefMessage(byte[] typeBytes, byte[] payload)
+ {
+ return BuildNdefRecord(typeBytes, payload, isFirst: true, isLast: true);
+ }
+
+ private static byte[] BuildNdefRecord(byte[] typeBytes, byte[] payload, bool isFirst, bool isLast, byte tnf = 0x02)
{
var typeLength = (byte)typeBytes.Length;
bool isShortRecord = payload.Length <= 255;
- var flags = (byte)(0xD0 | (isShortRecord ? 0x10 : 0x00) | 0x02);
+ byte flags = tnf;
+ if (isFirst) flags |= 0x80;
+ if (isLast) flags |= 0x40;
+ if (isShortRecord) flags |= 0x10;
using var ms = new MemoryStream();
ms.WriteByte(flags);
@@ -205,5 +239,8 @@ private class OpenSpoolPayload
public int? MaxTemp { get; set; }
[JsonPropertyName("_sm_spool_id")]
public string? SmSpoolId { get; set; }
+
+ [JsonPropertyName("spool_id")]
+ public int? SpoolmanId { get; set; }
}
}
diff --git a/src/SpoolManager.Server/Controllers/SpoolmanController.cs b/src/SpoolManager.Server/Controllers/SpoolmanController.cs
new file mode 100644
index 0000000..53398e6
--- /dev/null
+++ b/src/SpoolManager.Server/Controllers/SpoolmanController.cs
@@ -0,0 +1,184 @@
+using Microsoft.AspNetCore.Mvc;
+using SpoolManager.Infrastructure.Repositories;
+using SpoolManager.Server.Filters;
+using SpoolManager.Shared.DTOs.Spoolman;
+using SpoolManager.Shared.Models;
+
+namespace SpoolManager.Server.Controllers;
+
+[ApiController]
+[Route("{apiKey}/api/v1")]
+public class SpoolmanController : ControllerBase
+{
+ private readonly ISpoolRepository _spools;
+
+ public SpoolmanController(ISpoolRepository spools)
+ {
+ _spools = spools;
+ }
+
+ private Guid ProjectId => (Guid)HttpContext.Items["SpoolmanProjectId"]!;
+
+ [HttpGet("health")]
+ public IActionResult Health() => Ok(new SpoolmanHealthResponse());
+
+ [HttpGet("info")]
+ public IActionResult Info() => Ok(new SpoolmanInfoResponse());
+
+ [HttpGet("filament")]
+ [ServiceFilter(typeof(SpoolmanAuthFilter))]
+ public async Task ListFilaments()
+ {
+ var spools = await _spools.GetAllByProjectAsync(ProjectId);
+ var filaments = spools
+ .Where(s => s.FilamentMaterial != null)
+ .GroupBy(s => s.SpoolmanId)
+ .Select(g => g.First())
+ .Select(s => MapToSpoolmanResponse(s).Filament)
+ .ToList();
+ return Ok(filaments);
+ }
+
+ [HttpGet("vendor")]
+ [ServiceFilter(typeof(SpoolmanAuthFilter))]
+ public async Task ListVendors()
+ {
+ var spools = await _spools.GetAllByProjectAsync(ProjectId);
+ var vendors = spools
+ .Where(s => s.FilamentMaterial != null)
+ .Select(s => new SpoolmanVendorResponse
+ {
+ Id = (s.FilamentMaterial!.Brand ?? "Unknown").GetHashCode() & 0x7FFFFFFF,
+ Name = s.FilamentMaterial.Brand ?? "Unknown"
+ })
+ .GroupBy(v => v.Id)
+ .Select(g => g.First())
+ .ToList();
+ return Ok(vendors);
+ }
+
+ [HttpGet("spool/{id:int}")]
+ [ServiceFilter(typeof(SpoolmanAuthFilter))]
+ public async Task GetSpool(int id)
+ {
+ var spool = await _spools.GetBySpoolmanIdAsync(id, ProjectId);
+ if (spool == null) return NotFound();
+ return Ok(MapToSpoolmanResponse(spool));
+ }
+
+ [HttpGet("spool")]
+ [ServiceFilter(typeof(SpoolmanAuthFilter))]
+ public async Task ListSpools()
+ {
+ var spools = await _spools.GetAllByProjectAsync(ProjectId);
+ return Ok(spools.Select(MapToSpoolmanResponse).ToList());
+ }
+
+ [HttpPut("spool/{id:int}/use")]
+ [ServiceFilter(typeof(SpoolmanAuthFilter))]
+ public async Task UseSpool(int id, [FromBody] SpoolmanUseRequest request)
+ {
+ var spool = await _spools.GetBySpoolmanIdAsync(id, ProjectId);
+ if (spool == null) return NotFound();
+
+ var material = spool.FilamentMaterial;
+ if (material == null) return StatusCode(500, new { message = "Filament material not found." });
+
+ decimal subtractGrams;
+
+ if (request.UseWeight.HasValue && request.UseWeight.Value > 0)
+ {
+ subtractGrams = request.UseWeight.Value;
+ }
+ else if (request.UseLength.HasValue && request.UseLength.Value > 0)
+ {
+ if (!material.DensityGCm3.HasValue || material.DensityGCm3.Value <= 0)
+ return BadRequest(new { message = "Filament density not configured. Cannot convert length to weight." });
+
+ var diameterMm = material.DiameterMm > 0 ? material.DiameterMm : 1.75m;
+ var radiusCm = (diameterMm / 2m) / 10m;
+ var lengthCm = request.UseLength.Value / 10m;
+ var volumeCm3 = (decimal)Math.PI * radiusCm * radiusCm * lengthCm;
+ subtractGrams = volumeCm3 * material.DensityGCm3.Value;
+ }
+ else
+ {
+ return BadRequest(new { message = "Either use_weight or use_length must be provided." });
+ }
+
+ var totalWeight = material.WeightGrams ?? 0;
+ await _spools.UpdateRemainingWeightAtomicAsync(spool.Id, subtractGrams, totalWeight);
+
+ var updated = await _spools.GetBySpoolmanIdAsync(id, ProjectId);
+ return Ok(MapToSpoolmanResponse(updated!));
+ }
+
+ private static SpoolmanSpoolResponse MapToSpoolmanResponse(Spool spool)
+ {
+ var material = spool.FilamentMaterial;
+ decimal totalWeight = material?.WeightGrams ?? 0;
+ var remaining = spool.RemainingWeightGrams;
+ var used = totalWeight - remaining;
+ if (used < 0) used = 0;
+
+ decimal remainingLength = 0;
+ decimal usedLength = 0;
+
+ if (material?.DensityGCm3 is > 0 && material.DiameterMm > 0)
+ {
+ var radiusCm = (material.DiameterMm / 2m) / 10m;
+ var crossSectionCm2 = (decimal)Math.PI * radiusCm * radiusCm;
+ var densityGPerCm3 = material.DensityGCm3.Value;
+
+ if (crossSectionCm2 > 0 && densityGPerCm3 > 0)
+ {
+ remainingLength = (remaining / (crossSectionCm2 * densityGPerCm3)) * 10m;
+ usedLength = (used / (crossSectionCm2 * densityGPerCm3)) * 10m;
+ }
+ }
+
+ var vendorName = material?.Brand ?? "Unknown";
+ var vendorId = vendorName.GetHashCode() & 0x7FFFFFFF;
+
+ return new SpoolmanSpoolResponse
+ {
+ Id = spool.SpoolmanId,
+ Registered = spool.CreatedAt.ToString("o"),
+ FirstUsed = spool.OpenedAt?.ToString("o"),
+ LastUsed = spool.UpdatedAt.ToString("o"),
+ Filament = new SpoolmanFilamentResponse
+ {
+ Id = spool.SpoolmanId,
+ Name = $"{material?.Brand ?? ""} {material?.Type ?? ""}".Trim(),
+ Material = material?.Type ?? "",
+ Vendor = new SpoolmanVendorResponse
+ {
+ Id = vendorId,
+ Name = vendorName,
+ },
+ ColorHex = material?.ColorHex ?? "000000",
+ Diameter = material?.DiameterMm ?? 1.75m,
+ Density = material?.DensityGCm3 ?? 0,
+ Weight = totalWeight,
+ SpoolWeight = 0,
+ SettingsExtruderTemp = material?.MaxTempCelsius ?? 0,
+ SettingsBedTemp = material?.BedTempCelsius ?? 0,
+ },
+ RemainingWeight = remaining,
+ UsedWeight = used,
+ RemainingLength = Math.Round(remainingLength, 1),
+ UsedLength = Math.Round(usedLength, 1),
+ Archived = spool.ConsumedAt != null,
+ Extra = new Dictionary
+ {
+ ["spoolhero_id"] = spool.Id.ToString(),
+ },
+ };
+ }
+
+ [HttpGet("{**path}")]
+ [HttpPost("{**path}")]
+ [HttpPut("{**path}")]
+ [HttpDelete("{**path}")]
+ public IActionResult UnknownEndpoint() => Ok(new object());
+}
diff --git a/src/SpoolManager.Server/Controllers/SpoolmanSettingsController.cs b/src/SpoolManager.Server/Controllers/SpoolmanSettingsController.cs
new file mode 100644
index 0000000..1215255
--- /dev/null
+++ b/src/SpoolManager.Server/Controllers/SpoolmanSettingsController.cs
@@ -0,0 +1,93 @@
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SpoolManager.Infrastructure.Repositories;
+using SpoolManager.Server.Filters;
+using SpoolManager.Shared.DTOs.Spoolman;
+using SpoolManager.Shared.Models;
+
+namespace SpoolManager.Server.Controllers;
+
+[ApiController]
+[Route("api/spoolman/apikeys")]
+[Authorize]
+[ServiceFilter(typeof(ProjectAuthFilter))]
+public class SpoolmanSettingsController : ControllerBase
+{
+ private readonly ISpoolmanApiKeyRepository _apiKeys;
+ private readonly ISpoolmanCallLogRepository _callLogs;
+
+ public SpoolmanSettingsController(ISpoolmanApiKeyRepository apiKeys, ISpoolmanCallLogRepository callLogs)
+ {
+ _apiKeys = apiKeys;
+ _callLogs = callLogs;
+ }
+
+ private ProjectMember ProjectMember => (ProjectMember)HttpContext.Items["ProjectMember"]!;
+
+ [HttpGet]
+ public async Task GetAll()
+ {
+ var keys = await _apiKeys.GetAllByProjectAsync(ProjectMember.ProjectId);
+ return Ok(keys.Select(k => new SpoolmanApiKeyDto
+ {
+ Id = k.Id,
+ ApiKey = k.ApiKey,
+ Name = k.Name,
+ CreatedAt = k.CreatedAt,
+ LastUsedAt = k.LastUsedAt,
+ }).ToList());
+ }
+
+ [HttpPost]
+ public async Task Create([FromBody] CreateSpoolmanApiKeyRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Name))
+ return BadRequest(new { message = "Name is required." });
+
+ var key = new SpoolmanApiKey
+ {
+ ProjectId = ProjectMember.ProjectId,
+ ApiKey = GenerateApiKey(),
+ Name = request.Name.Trim(),
+ };
+
+ await _apiKeys.CreateAsync(key);
+
+ return Ok(new SpoolmanApiKeyDto
+ {
+ Id = key.Id,
+ ApiKey = key.ApiKey,
+ Name = key.Name,
+ CreatedAt = key.CreatedAt,
+ });
+ }
+
+ [HttpDelete("{id:guid}")]
+ public async Task Delete(Guid id)
+ {
+ await _apiKeys.DeleteAsync(id);
+ return NoContent();
+ }
+
+ [HttpGet("{id:guid}/logs")]
+ public async Task GetLogs(Guid id)
+ {
+ var logs = await _callLogs.GetLast24hAsync(id);
+ return Ok(logs.Select(l => new SpoolmanCallLogDto
+ {
+ CalledAt = l.CalledAt,
+ Method = l.Method,
+ Path = l.Path,
+ StatusCode = l.StatusCode,
+ }).ToList());
+ }
+
+ private static string GenerateApiKey()
+ {
+ var bytes = new byte[32];
+ using var rng = RandomNumberGenerator.Create();
+ rng.GetBytes(bytes);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+}
diff --git a/src/SpoolManager.Server/Controllers/SpoolsController.cs b/src/SpoolManager.Server/Controllers/SpoolsController.cs
index 8176a1b..48a79ae 100644
--- a/src/SpoolManager.Server/Controllers/SpoolsController.cs
+++ b/src/SpoolManager.Server/Controllers/SpoolsController.cs
@@ -192,6 +192,7 @@ public async Task UpdateRemaining(Guid id, UpdateRemainingRequest
private static SpoolDto MapToDto(Spool s) => new()
{
Id = s.Id,
+ SpoolmanId = s.SpoolmanId,
ProjectId = s.ProjectId,
FilamentMaterialId = s.FilamentMaterialId,
MaterialType = s.FilamentMaterial?.Type ?? string.Empty,
diff --git a/src/SpoolManager.Server/Controllers/TagsController.cs b/src/SpoolManager.Server/Controllers/TagsController.cs
index 7cebc8f..0ddb836 100644
--- a/src/SpoolManager.Server/Controllers/TagsController.cs
+++ b/src/SpoolManager.Server/Controllers/TagsController.cs
@@ -43,9 +43,9 @@ public async Task Encode(TagEncodeRequest request)
var spool = await _spools.GetByIdAsync(request.SpoolId.Value, ProjectMember.ProjectId);
if (spool?.FilamentMaterial == null) return NotFound();
- var ndefBytes = _openSpool.Encode(spool.FilamentMaterial, spool.Id);
- var json = _openSpool.ToJson(spool.FilamentMaterial, spool.Id);
- return Ok(new TagEncodeResponse { Base64 = Convert.ToBase64String(ndefBytes), JsonPayload = json });
+ var ndefBytes = _openSpool.Encode(spool.FilamentMaterial, spool.Id, spool.SpoolmanId);
+ var json = _openSpool.ToJson(spool.FilamentMaterial, spool.Id, spool.SpoolmanId > 0 ? spool.SpoolmanId : null);
+ return Ok(new TagEncodeResponse { Base64 = Convert.ToBase64String(ndefBytes), JsonPayload = json, SpoolmanId = spool.SpoolmanId > 0 ? spool.SpoolmanId : null });
}
else if (request.MaterialId.HasValue)
{
@@ -138,7 +138,7 @@ public async Task Download(Guid spoolId)
var spool = await _spools.GetByIdAsync(spoolId, ProjectMember.ProjectId);
if (spool?.FilamentMaterial == null) return NotFound();
- var bytes = _openSpool.Encode(spool.FilamentMaterial, spool.Id);
+ var bytes = _openSpool.Encode(spool.FilamentMaterial, spool.Id, spool.SpoolmanId);
var filename = $"openspool_{spool.FilamentMaterial.Brand}_{spool.FilamentMaterial.Type}_{spoolId}.bin".Replace(" ", "_");
return File(bytes, "application/octet-stream", filename);
}
diff --git a/src/SpoolManager.Server/Filters/SpoolmanAuthFilter.cs b/src/SpoolManager.Server/Filters/SpoolmanAuthFilter.cs
new file mode 100644
index 0000000..f1716ab
--- /dev/null
+++ b/src/SpoolManager.Server/Filters/SpoolmanAuthFilter.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using SpoolManager.Infrastructure.Repositories;
+
+namespace SpoolManager.Server.Filters;
+
+public class SpoolmanAuthFilter : IAsyncActionFilter
+{
+ private readonly ISpoolmanApiKeyRepository _apiKeys;
+ private readonly ISpoolmanCallLogRepository _callLogs;
+
+ public SpoolmanAuthFilter(ISpoolmanApiKeyRepository apiKeys, ISpoolmanCallLogRepository callLogs)
+ {
+ _apiKeys = apiKeys;
+ _callLogs = callLogs;
+ }
+
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var apiKeyValue = context.RouteData.Values["apiKey"]?.ToString();
+
+ if (string.IsNullOrEmpty(apiKeyValue))
+ {
+ context.Result = new UnauthorizedObjectResult(new { message = "API key missing from URL." });
+ return;
+ }
+
+ var key = await _apiKeys.GetByApiKeyAsync(apiKeyValue);
+ if (key == null)
+ {
+ context.Result = new UnauthorizedObjectResult(new { message = "Invalid API key." });
+ return;
+ }
+
+ context.HttpContext.Items["SpoolmanProjectId"] = key.ProjectId;
+ context.HttpContext.Items["SpoolmanApiKeyId"] = key.Id;
+
+ _ = Task.Run(async () =>
+ {
+ try { await _apiKeys.UpdateLastUsedAsync(key.Id); } catch { }
+ });
+
+ var result = await next();
+
+ var statusCode = context.HttpContext.Response.StatusCode;
+ var method = context.HttpContext.Request.Method;
+ var path = context.HttpContext.Request.Path.Value ?? "/";
+
+ _ = Task.Run(async () =>
+ {
+ try { await _callLogs.LogAsync(key.Id, method, path, statusCode); } catch { }
+ });
+ }
+}
diff --git a/src/SpoolManager.Server/Middleware/SpoolmanWebSocketMiddleware.cs b/src/SpoolManager.Server/Middleware/SpoolmanWebSocketMiddleware.cs
new file mode 100644
index 0000000..2a0fb91
--- /dev/null
+++ b/src/SpoolManager.Server/Middleware/SpoolmanWebSocketMiddleware.cs
@@ -0,0 +1,64 @@
+using System.Net.WebSockets;
+using System.Text.RegularExpressions;
+using SpoolManager.Infrastructure.Repositories;
+
+namespace SpoolManager.Server.Middleware;
+
+public partial class SpoolmanWebSocketMiddleware
+{
+ private readonly RequestDelegate _next;
+
+ public SpoolmanWebSocketMiddleware(RequestDelegate next) => _next = next;
+
+ public async Task InvokeAsync(HttpContext context, ISpoolmanApiKeyRepository apiKeys)
+ {
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ await _next(context);
+ return;
+ }
+
+ var path = context.Request.Path.Value ?? "";
+ var match = SpoolPath().Match(path);
+ if (!match.Success)
+ {
+ await _next(context);
+ return;
+ }
+
+ var apiKeyValue = match.Groups[1].Value;
+ var key = await apiKeys.GetByApiKeyAsync(apiKeyValue);
+ if (key == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
+ using var ws = await context.WebSockets.AcceptWebSocketAsync();
+ await KeepAliveAsync(ws, context.RequestAborted);
+ }
+
+ private static async Task KeepAliveAsync(WebSocket ws, CancellationToken ct)
+ {
+ var buffer = new byte[4096];
+ while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
+ {
+ try
+ {
+ var result = await ws.ReceiveAsync(new ArraySegment(buffer), ct);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
+ return;
+ }
+ }
+ catch (OperationCanceledException) { return; }
+ catch { return; }
+ }
+ if (ws.State == WebSocketState.Open)
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
+ }
+
+ [GeneratedRegex(@"^/([^/]+)/api/v1/spool$", RegexOptions.IgnoreCase)]
+ private static partial Regex SpoolPath();
+}
diff --git a/src/SpoolManager.Server/Program.cs b/src/SpoolManager.Server/Program.cs
index 569af71..62281ad 100644
--- a/src/SpoolManager.Server/Program.cs
+++ b/src/SpoolManager.Server/Program.cs
@@ -47,7 +47,10 @@
builder.Services.AddScoped();
builder.Services.AddHostedService();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
@@ -143,8 +146,10 @@
app.UseStaticFiles();
+app.UseWebSockets();
app.UseAuthentication();
app.UseMiddleware();
+app.UseMiddleware();
app.UseAuthorization();
app.MapControllers();
diff --git a/src/SpoolManager.Shared/DTOs/Spoolman/SpoolmanDtos.cs b/src/SpoolManager.Shared/DTOs/Spoolman/SpoolmanDtos.cs
new file mode 100644
index 0000000..97cacac
--- /dev/null
+++ b/src/SpoolManager.Shared/DTOs/Spoolman/SpoolmanDtos.cs
@@ -0,0 +1,136 @@
+using System.Text.Json.Serialization;
+
+namespace SpoolManager.Shared.DTOs.Spoolman;
+
+public class SpoolmanSpoolResponse
+{
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("registered")]
+ public string Registered { get; set; } = string.Empty;
+
+ [JsonPropertyName("first_used")]
+ public string? FirstUsed { get; set; }
+
+ [JsonPropertyName("last_used")]
+ public string? LastUsed { get; set; }
+
+ [JsonPropertyName("filament")]
+ public SpoolmanFilamentResponse Filament { get; set; } = new();
+
+ [JsonPropertyName("remaining_weight")]
+ public decimal RemainingWeight { get; set; }
+
+ [JsonPropertyName("used_weight")]
+ public decimal UsedWeight { get; set; }
+
+ [JsonPropertyName("remaining_length")]
+ public decimal RemainingLength { get; set; }
+
+ [JsonPropertyName("used_length")]
+ public decimal UsedLength { get; set; }
+
+ [JsonPropertyName("archived")]
+ public bool Archived { get; set; }
+
+ [JsonPropertyName("extra")]
+ public Dictionary Extra { get; set; } = new();
+}
+
+public class SpoolmanFilamentResponse
+{
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("material")]
+ public string Material { get; set; } = string.Empty;
+
+ [JsonPropertyName("vendor")]
+ public SpoolmanVendorResponse Vendor { get; set; } = new();
+
+ [JsonPropertyName("color_hex")]
+ public string ColorHex { get; set; } = string.Empty;
+
+ [JsonPropertyName("diameter")]
+ public decimal Diameter { get; set; }
+
+ [JsonPropertyName("density")]
+ public decimal Density { get; set; }
+
+ [JsonPropertyName("weight")]
+ public decimal Weight { get; set; }
+
+ [JsonPropertyName("spool_weight")]
+ public decimal SpoolWeight { get; set; }
+
+ [JsonPropertyName("settings_extruder_temp")]
+ public int SettingsExtruderTemp { get; set; }
+
+ [JsonPropertyName("settings_bed_temp")]
+ public int SettingsBedTemp { get; set; }
+}
+
+public class SpoolmanVendorResponse
+{
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+}
+
+public class SpoolmanUseRequest
+{
+ [JsonPropertyName("use_weight")]
+ public decimal? UseWeight { get; set; }
+
+ [JsonPropertyName("use_length")]
+ public decimal? UseLength { get; set; }
+}
+
+public class SpoolmanHealthResponse
+{
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = "healthy";
+}
+
+public class SpoolmanInfoResponse
+{
+ [JsonPropertyName("version")]
+ public string Version { get; set; } = "1.0.0";
+
+ [JsonPropertyName("automatic_backups")]
+ public bool AutomaticBackups { get; set; }
+
+ [JsonPropertyName("data_dir")]
+ public string DataDir { get; set; } = string.Empty;
+
+ [JsonPropertyName("logs_dir")]
+ public string LogsDir { get; set; } = string.Empty;
+}
+
+public class SpoolmanApiKeyDto
+{
+ public Guid Id { get; set; }
+ public string ApiKey { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? LastUsedAt { get; set; }
+}
+
+public class CreateSpoolmanApiKeyRequest
+{
+ public string Name { get; set; } = string.Empty;
+}
+
+public class SpoolmanCallLogDto
+{
+ public DateTime CalledAt { get; set; }
+ public string Method { get; set; } = string.Empty;
+ public string Path { get; set; } = string.Empty;
+ public int StatusCode { get; set; }
+}
diff --git a/src/SpoolManager.Shared/DTOs/Spools/SpoolDtos.cs b/src/SpoolManager.Shared/DTOs/Spools/SpoolDtos.cs
index 3e589c9..3f9cbf2 100644
--- a/src/SpoolManager.Shared/DTOs/Spools/SpoolDtos.cs
+++ b/src/SpoolManager.Shared/DTOs/Spools/SpoolDtos.cs
@@ -3,6 +3,7 @@ namespace SpoolManager.Shared.DTOs.Spools;
public class SpoolDto
{
public Guid Id { get; set; }
+ public int SpoolmanId { get; set; }
public Guid ProjectId { get; set; }
public Guid FilamentMaterialId { get; set; }
public string MaterialType { get; set; } = string.Empty;
diff --git a/src/SpoolManager.Shared/DTOs/Tags/TagDtos.cs b/src/SpoolManager.Shared/DTOs/Tags/TagDtos.cs
index 0dfce0b..8f969f7 100644
--- a/src/SpoolManager.Shared/DTOs/Tags/TagDtos.cs
+++ b/src/SpoolManager.Shared/DTOs/Tags/TagDtos.cs
@@ -10,6 +10,7 @@ public class TagEncodeResponse
{
public string Base64 { get; set; } = string.Empty;
public string? JsonPayload { get; set; }
+ public int? SpoolmanId { get; set; }
}
public class TagDecodeRequest
diff --git a/src/SpoolManager.Shared/Models/Spool.cs b/src/SpoolManager.Shared/Models/Spool.cs
index d1e6f99..e35dbda 100644
--- a/src/SpoolManager.Shared/Models/Spool.cs
+++ b/src/SpoolManager.Shared/Models/Spool.cs
@@ -3,6 +3,7 @@ namespace SpoolManager.Shared.Models;
public class Spool
{
public Guid Id { get; set; }
+ public int SpoolmanId { get; set; }
public Guid ProjectId { get; set; }
public Guid FilamentMaterialId { get; set; }
public FilamentMaterial? FilamentMaterial { get; set; }
diff --git a/src/SpoolManager.Shared/Models/SpoolmanApiKey.cs b/src/SpoolManager.Shared/Models/SpoolmanApiKey.cs
new file mode 100644
index 0000000..e50e249
--- /dev/null
+++ b/src/SpoolManager.Shared/Models/SpoolmanApiKey.cs
@@ -0,0 +1,11 @@
+namespace SpoolManager.Shared.Models;
+
+public class SpoolmanApiKey
+{
+ public Guid Id { get; set; }
+ public Guid ProjectId { get; set; }
+ public string ApiKey { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? LastUsedAt { get; set; }
+}
diff --git a/src/SpoolManager.Shared/Models/SpoolmanCallLog.cs b/src/SpoolManager.Shared/Models/SpoolmanCallLog.cs
new file mode 100644
index 0000000..38a38bd
--- /dev/null
+++ b/src/SpoolManager.Shared/Models/SpoolmanCallLog.cs
@@ -0,0 +1,11 @@
+namespace SpoolManager.Shared.Models;
+
+public class SpoolmanCallLog
+{
+ public Guid Id { get; set; }
+ public Guid ApiKeyId { get; set; }
+ public DateTime CalledAt { get; set; }
+ public string Method { get; set; } = string.Empty;
+ public string Path { get; set; } = string.Empty;
+ public int StatusCode { get; set; }
+}