├── UI ├── src │ ├── assets │ │ └── .gitkeep │ ├── app │ │ ├── app.component.scss │ │ ├── shared │ │ │ ├── components │ │ │ │ ├── scan │ │ │ │ │ ├── scan.component.scss │ │ │ │ │ ├── scan.component.html │ │ │ │ │ └── scan.component.ts │ │ │ │ ├── spool │ │ │ │ │ ├── spool.component.scss │ │ │ │ │ ├── spool.component.html │ │ │ │ │ └── spool.component.ts │ │ │ │ └── tray │ │ │ │ │ ├── tray.component.scss │ │ │ │ │ ├── tray.component.html │ │ │ │ │ └── tray.component.ts │ │ │ ├── models │ │ │ │ ├── updatetray.ts │ │ │ │ ├── vendor.ts │ │ │ │ ├── tray.ts │ │ │ │ ├── spool.ts │ │ │ │ └── filament.ts │ │ │ └── service │ │ │ │ ├── tray.service.ts │ │ │ │ └── spoolman.service.ts │ │ ├── app.routes.ts │ │ ├── app.config.ts │ │ ├── app.component.html │ │ ├── components │ │ │ ├── scan │ │ │ │ ├── scan.component.scss │ │ │ │ ├── scan.component.html │ │ │ │ └── scan.component.ts │ │ │ └── spool │ │ │ │ ├── spool.component.html │ │ │ │ ├── spool.component.ts │ │ │ │ └── spool.component.scss │ │ └── app.component.ts │ ├── favicon.ico │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── styles.scss │ ├── main.ts │ └── index.html ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── package.json └── angular.json ├── Application ├── .dockerignore ├── Controllers │ ├── Trays.cs │ └── Spools.cs ├── appsettings.json ├── Application.csproj ├── Dockerfile ├── Program.cs └── Properties │ └── launchSettings.json ├── Domain ├── UseCases │ ├── IInput.cs │ ├── Tray │ │ └── GetAll │ │ │ ├── Input.cs │ │ │ ├── Output.cs │ │ │ └── UseCase.cs │ ├── Spool │ │ ├── GetAll │ │ │ ├── Input.cs │ │ │ ├── Output.cs │ │ │ └── UseCase.cs │ │ ├── Update │ │ │ ├── Output.cs │ │ │ ├── Input.cs │ │ │ └── UseCase.cs │ │ ├── Tray │ │ │ ├── Output.cs │ │ │ ├── Input.cs │ │ │ └── UseCase.cs │ │ └── GetByBarcode │ │ │ ├── Input.cs │ │ │ ├── Output.cs │ │ │ └── UseCase.cs │ ├── IUseCase.cs │ ├── IOutput.cs │ └── InputHandler.cs ├── Configuration │ └── Configuration.cs ├── Extensions │ ├── Assembly.cs │ ├── Type.cs │ └── ServiceCollection.cs └── Domain.csproj ├── image.png ├── Gateways ├── Spoolman │ ├── Mappings │ │ └── Brand.cs │ ├── Entities │ │ ├── Health.cs │ │ ├── Field.cs │ │ ├── Vendor.cs │ │ ├── Spool.cs │ │ └── Filament.cs │ ├── Enums │ │ └── EntityType.cs │ ├── Configuration.cs │ ├── Endpoints │ │ ├── Health.cs │ │ ├── Vendor.cs │ │ ├── ISpoolmanEndpoint.cs │ │ ├── Filament.cs │ │ ├── Base.cs │ │ ├── Field.cs │ │ └── Spool.cs │ ├── Constants │ │ └── QueryConstants.cs │ └── Client.cs ├── IHealthEndpoint.cs ├── HomeAssistant │ ├── Entities │ │ ├── HaDevice.cs │ │ ├── AMSEntity.cs │ │ ├── HomeAssistantState.cs │ │ ├── TrayInfo.cs │ │ ├── HaEntity.cs │ │ └── BambuServiceRequest.cs │ ├── Configuration.cs │ └── Client.cs ├── GatewayChecker.cs ├── Gateways.csproj └── LoggingHandler.cs ├── Gateways.Tests ├── Spoolman │ └── Endpoints │ │ ├── HealthTests.cs │ │ ├── VendorTests.cs │ │ ├── EndpointTest.cs │ │ ├── FieldTests.cs │ │ ├── FilamentTests.cs │ │ └── SpoolTests.cs └── Gateways.Tests.csproj ├── LICENSE.txt ├── .dockerignore ├── unraid_template.xml ├── SpoolmanUpdater.sln ├── .github └── workflows │ └── docker-image.yml ├── .gitattributes ├── .gitignore └── README.md /UI/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Application/.dockerignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /UI/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | .title { 2 | flex: 1; 3 | } -------------------------------------------------------------------------------- /Domain/UseCases/IInput.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public interface IInput { } 4 | -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcokreeft87/spoolman-updater/HEAD/image.png -------------------------------------------------------------------------------- /UI/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcokreeft87/spoolman-updater/HEAD/UI/src/favicon.ico -------------------------------------------------------------------------------- /Domain/UseCases/Tray/GetAll/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public class GetAllAMSInput : IInput 4 | { 5 | } -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetAll/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public class GetAllSpoolsInput : IInput 4 | { 5 | } -------------------------------------------------------------------------------- /UI/src/app/shared/components/scan/scan.component.scss: -------------------------------------------------------------------------------- 1 | .camera-selector { 2 | position: relative; 3 | z-index: 1000; 4 | } -------------------------------------------------------------------------------- /UI/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiBaseUrl: '/', 4 | }; 5 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Update/Output.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | internal class UpdateSpoolOutput(bool success) : IOutput 4 | { 5 | } -------------------------------------------------------------------------------- /UI/src/app/shared/models/updatetray.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateTrayInput { 2 | spool_id: string; 3 | active_tray_id?: string; 4 | } -------------------------------------------------------------------------------- /Gateways/Spoolman/Mappings/Brand.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public record VendorMapping(string Pattern, string NewVendorName); 4 | -------------------------------------------------------------------------------- /Gateways/IHealthEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public interface IHealthEndpoint 4 | { 5 | Task CheckHealthAsync(); 6 | } -------------------------------------------------------------------------------- /Gateways/Spoolman/Entities/Health.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class Health 4 | { 5 | public bool IsHealthy { get; set; } 6 | } -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Tray/Output.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal record UpdateTrayOutput(Spool Spool) : IOutput; -------------------------------------------------------------------------------- /UI/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: false, 3 | apiBaseUrl: 'https://localhost:7042/', 4 | }; 5 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Enums/EntityType.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public enum EntityType 4 | { 5 | Vendor, 6 | Filament, 7 | Spool 8 | } 9 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Configuration.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class SpoolmanConfiguration 4 | { 5 | public string Url { get; set; } = string.Empty; 6 | } 7 | -------------------------------------------------------------------------------- /UI/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /Domain/UseCases/IUseCase.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public interface IUseCase where TInput : IInput 4 | { 5 | Task ExecuteAsync(TInput input); 6 | } 7 | -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/HaDevice.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class HaDevice 4 | { 5 | public string Id { get; set; } 6 | public string Name { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Tray/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public class UpdateTrayInput : IInput 4 | { 5 | public int SpoolId { get; set; } 6 | public string ActiveTrayId { get; set; } 7 | } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/AMSEntity.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class AMSEntity 4 | { 5 | public string Id { get; set; } = string.Empty; 6 | 7 | public List Trays { get; set; } = new(); 8 | } -------------------------------------------------------------------------------- /UI/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; background-color: #191c1c; } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/HomeAssistantState.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class HomeAssistantState 4 | { 5 | public string State { get; set; } = string.Empty; 6 | public TrayInfo Attributes { get; set; } = new(); 7 | } 8 | -------------------------------------------------------------------------------- /UI/src/app/shared/components/spool/spool.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 15px; 6 | 7 | .spool-color { 8 | height: 20px; 9 | padding: 8px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Domain/UseCases/IOutput.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public interface IOutput 4 | { 5 | } 6 | 7 | public static class Output 8 | { 9 | public static IOutput Empty => new EmptyOutput(); 10 | 11 | private sealed class EmptyOutput : IOutput { } 12 | } -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetByBarcode/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public class GetByBarcodeInput : IInput 4 | { 5 | public GetByBarcodeInput(string barcode) 6 | { 7 | Barcode = barcode; 8 | } 9 | public string Barcode { get; } 10 | } -------------------------------------------------------------------------------- /UI/src/app/shared/models/vendor.ts: -------------------------------------------------------------------------------- 1 | export interface Vendor { 2 | id: number; 3 | registered: string; 4 | name: string; 5 | comment: string | null; 6 | empty_spool_weight: number | null; 7 | external_id: string; 8 | extra: Record; 9 | } 10 | -------------------------------------------------------------------------------- /UI/src/app/shared/models/tray.ts: -------------------------------------------------------------------------------- 1 | export interface Tray { 2 | id: string; 3 | name: string; 4 | color: string; 5 | tag_uid: string; 6 | type: string; 7 | selectedSpool: string; 8 | } 9 | 10 | export interface AMSEntity { 11 | id: string; 12 | trays: Tray[]; 13 | } -------------------------------------------------------------------------------- /UI/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /Domain/Configuration/Configuration.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | public class UpdaterConfiguration 6 | { 7 | public SpoolmanConfiguration Spoolman { get; set; } = new(); 8 | 9 | public HomeAssistantConfiguration HomeAssistant { get; set; } = new(); 10 | } 11 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetAll/Output.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal class GetAllSpoolsOutput : IOutput 6 | { 7 | public GetAllSpoolsOutput(List spools) 8 | { 9 | Spools = spools; 10 | } 11 | 12 | public List Spools { get; } 13 | } -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetByBarcode/Output.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal class GetByBarcodeOutput : IOutput 6 | { 7 | public GetByBarcodeOutput(List spools) 8 | { 9 | Spools = spools; 10 | } 11 | 12 | public List Spools { get; } 13 | } -------------------------------------------------------------------------------- /Gateways/Spoolman/Entities/Field.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class Field 4 | { 5 | public string Name { get; set; } 6 | public int Order { get; set; } 7 | public string FieldType { get; set; } 8 | public string Key { get; set; } 9 | public string EntityType { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /UI/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /UI/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { ScanComponent } from './components/scan/scan.component'; 3 | import { SpoolComponent } from './components/spool/spool.component'; 4 | 5 | export const routes: Routes = [ 6 | { path: '', component: SpoolComponent }, 7 | { path: 'scan', component: ScanComponent } 8 | ]; 9 | -------------------------------------------------------------------------------- /UI/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /UI/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /UI/src/app/shared/components/tray/tray.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 15px; 6 | 7 | .tray { 8 | width: 100%; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 15px; 12 | 13 | .spool-color { 14 | height: 20px; 15 | padding: 8px; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Update/Input.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public class UpdateSpoolInput : IInput 4 | { 5 | public string Name { get; set; } 6 | public string Color { get; set; } 7 | public string Material { get; set; } 8 | public string TagUid { get; set; } 9 | public float UsedWeight { get; set; } 10 | public string ActiveTrayId { get; set; } 11 | } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/TrayInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class TrayInfo 4 | { 5 | public string Id { get; set; } = string.Empty; 6 | public string Name { get; set; } = string.Empty; 7 | public string Color { get; set; } = string.Empty; 8 | public string TagUid { get; set; } = string.Empty; 9 | public string Type { get; set; } = string.Empty; 10 | } 11 | -------------------------------------------------------------------------------- /UI/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | 4 | import { routes } from './app.routes'; 5 | import { provideAnimations } from '@angular/platform-browser/animations'; 6 | 7 | export const appConfig: ApplicationConfig = { 8 | providers: [ 9 | provideRouter(routes), 10 | provideAnimations()] 11 | }; 12 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetAll/UseCase.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal sealed class GetAllSpoolsUseCase(SpoolmanClient spoolmanClient) : IUseCase 6 | { 7 | public async Task ExecuteAsync(GetAllSpoolsInput input) 8 | { 9 | var spools = await spoolmanClient.GetAllAsync(); 10 | 11 | return new GetAllSpoolsOutput(spools); 12 | } 13 | } -------------------------------------------------------------------------------- /Application/Controllers/Trays.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Application.Controllers; 5 | 6 | [ApiController] 7 | [Route("[controller]")] 8 | public class TraysController(IInputHandler handler) : ControllerBase 9 | { 10 | [HttpGet] 11 | public async Task Get([FromQuery] GetAllAMSInput input) => 12 | Ok(await handler.HandleAsync(input)); 13 | } 14 | -------------------------------------------------------------------------------- /UI/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | Spoolman Updater 3 | 4 | 7 | 8 | 9 |
10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/GetByBarcode/UseCase.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal sealed class GetByBarcodeUseCase(SpoolmanClient spoolmanClient) : IUseCase 6 | { 7 | public async Task ExecuteAsync(GetByBarcodeInput input) 8 | { 9 | var spools = await spoolmanClient.GetByBarcodeAsync(input.Barcode); 10 | 11 | return new GetAllSpoolsOutput(spools); 12 | } 13 | } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/HaEntity.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Gateways; 4 | 5 | public class HaEntity 6 | { 7 | [JsonPropertyName("entity_id")] 8 | public string EntityId { get; set; } 9 | [JsonPropertyName("device_id")] 10 | public string DeviceId { get; set; } 11 | public string State { get; set; } 12 | public Dictionary Attributes { get; set; } 13 | } 14 | -------------------------------------------------------------------------------- /Application/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Application": { 10 | "HomeAssistant": { 11 | "Url": "", 12 | "Token": "", 13 | "TraySensorPrefix": "", 14 | "AMSEntities": [] 15 | }, 16 | "Spoolman": { 17 | "Url": "" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Domain/UseCases/Tray/GetAll/Output.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal class GetAllAMSOutput : IOutput 6 | { 7 | public GetAllAMSOutput(List amsEntities, TrayInfo extranalTray) 8 | { 9 | AMSEntities = amsEntities; 10 | ExternalSpoolEntity = extranalTray; 11 | } 12 | 13 | public List AMSEntities { get; } 14 | 15 | public TrayInfo? ExternalSpoolEntity { get; } 16 | } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Configuration.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class HomeAssistantConfiguration 4 | { 5 | public string Url { get; set; } = string.Empty; 6 | public string Token { get; set; } = string.Empty; 7 | public string ExternalSpoolEntity { get; set; } = string.Empty; 8 | public string[] AMSEntities { get; set; } = Array.Empty(); 9 | public string[] TrayEntities { get; set;} = Array.Empty(); 10 | } 11 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Health.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | internal class HealthCheckSpoolmanEndpoint : SpoolmanEndpoint, IHealthEndpoint 4 | { 5 | protected override string Endpoint => "health"; 6 | 7 | public HealthCheckSpoolmanEndpoint(SpoolmanConfiguration configuration) : base(configuration) { } 8 | 9 | public async Task CheckHealthAsync() => 10 | (await HttpClient.GetAsync(Endpoint)).IsSuccessStatusCode; 11 | } -------------------------------------------------------------------------------- /Gateways/Spoolman/Entities/Vendor.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | 4 | using System.Collections.Generic; 5 | 6 | public class Vendor 7 | { 8 | public int? Id { get; set; } 9 | public string Registered { get; set; } 10 | public string Name { get; set; } 11 | public string Comment { get; set; } 12 | public double? EmptySpoolWeight { get; set; } 13 | public string ExternalId { get; set; } 14 | public Dictionary Extra { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /Gateways/GatewayChecker.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public class GatewayChecker(SpoolmanClient SpoolmanClient) 4 | { 5 | public async Task CheckGatewayConnectionAsync() 6 | { 7 | if (!await SpoolmanClient.CheckHealthAsync()) 8 | { 9 | Console.WriteLine("Spoolman is not available."); 10 | return false; 11 | } 12 | 13 | await SpoolmanClient.CheckFieldExistence(); 14 | 15 | return true; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Domain/Extensions/Assembly.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace Domain; 4 | 5 | public static class AssemblyExtensions 6 | { 7 | public static IEnumerable GetImplementations(this Assembly assembly, Type interfaceType) => 8 | assembly 9 | .GetTypes() 10 | .Where(type => type.IsClass) 11 | .Where(type => !type.IsAbstract) 12 | .Where(type => !type.IsGenericType) 13 | .Where(type => type.Implements(interfaceType)); 14 | } 15 | -------------------------------------------------------------------------------- /UI/src/app/shared/components/scan/scan.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /Domain/UseCases/Tray/GetAll/UseCase.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal sealed class GetAllAMSUseCase(HomeAssistantClient homeAssistantClient) : IUseCase 6 | { 7 | public async Task ExecuteAsync(GetAllAMSInput input) 8 | { 9 | var amsEntities = await homeAssistantClient.GetAmsInfoAsync(); 10 | 11 | var externalSpool = await homeAssistantClient.GetExternalSpoolAsync(); 12 | 13 | return new GetAllAMSOutput(amsEntities, externalSpool); 14 | } 15 | } -------------------------------------------------------------------------------- /UI/src/app/shared/components/spool/spool.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ spool.filament.vendor.name }} 3 | {{ spool.filament.material }} 4 | {{ spool.filament.name }} 5 | 6 |
11 | #{{ spool.filament.color_hex }} 12 |
13 | 14 | Remaining: {{ spool.remaining_weight }}g 15 |
16 | -------------------------------------------------------------------------------- /Domain/UseCases/InputHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Domain; 4 | 5 | public interface IInputHandler 6 | { 7 | Task HandleAsync(TInput input) where TInput : IInput; 8 | } 9 | 10 | internal sealed class InputHandler(IServiceProvider serviceProvider) : IInputHandler 11 | { 12 | public async Task HandleAsync(TInput input) where TInput : IInput => 13 | await serviceProvider 14 | .GetService>() 15 | .ExecuteAsync(input); 16 | } 17 | -------------------------------------------------------------------------------- /UI/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /Gateways/Gateways.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /UI/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SpoolmanUpdater 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Constants/QueryConstants.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | internal static class FilamentQueryConstants 4 | { 5 | private static string Prefix = "filament"; 6 | 7 | public static string FilamentVendorName => $"{Prefix}.{VendorName}"; 8 | 9 | public static string FilamentMaterial => $"{Prefix}.{Material}"; 10 | 11 | public static string ColorHex = "color_hex"; 12 | public static string Material = "material"; 13 | 14 | public static string VendorName = "vendor.name"; 15 | 16 | public static string ActiveTrayId = "active_tray"; 17 | public static string TagUid = "tag"; 18 | } -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Update/UseCase.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal sealed class UpdateSpoolUseCase(SpoolmanClient spoolmanClient) : IUseCase 6 | { 7 | public async Task ExecuteAsync(UpdateSpoolInput input) 8 | { 9 | var spool = await spoolmanClient.GetSpoolByBrandAndColorAsync(input.Name, input.Material, input.Color, input.ActiveTrayId, input.TagUid); 10 | if (spool == null) 11 | return new UpdateSpoolOutput(false); 12 | 13 | var success = await spoolmanClient.UseSpoolWeightAsync(spool.Id.Value, input.UsedWeight); 14 | 15 | return new UpdateSpoolOutput(success); 16 | } 17 | } -------------------------------------------------------------------------------- /UI/src/app/components/scan/scan.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | color: white; 3 | margin: 24px; 4 | display: flex; 5 | flex-direction: column; 6 | 7 | .ams-container { 8 | display: flex; 9 | gap: 16px; 10 | padding: 24px; 11 | 12 | .ams-card { 13 | color: white; 14 | background-color: gray; 15 | 16 | .ams-card-content { 17 | display: flex; 18 | gap: 16px; 19 | padding: 16px; 20 | } 21 | } 22 | } 23 | 24 | mat-card + mat-card { 25 | margin-top: 16px; 26 | } 27 | 28 | @media (width <= 750px) { 29 | .ams-container { 30 | flex-direction: column; 31 | gap: 16px; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /UI/src/app/shared/models/spool.ts: -------------------------------------------------------------------------------- 1 | import { Filament } from "./filament"; 2 | 3 | export interface Spool { 4 | id: number; 5 | registered: string; 6 | first_used: string; 7 | last_used: string; 8 | filament_id: number; 9 | filament: Filament; 10 | price: number | null; 11 | remaining_weight: number; 12 | initial_weight: number; 13 | spool_weight: number; 14 | used_weight: number; 15 | remaining_length: number; 16 | used_length: number; 17 | location: string | null; 18 | lot_number: string | null; 19 | comment: string | null; 20 | archived: boolean; 21 | extra: Record; 22 | } 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /UI/src/app/shared/models/filament.ts: -------------------------------------------------------------------------------- 1 | import { Vendor } from "./vendor"; 2 | 3 | export interface Filament { 4 | id: number; 5 | registered: string; 6 | name: string; 7 | vendor_id: number; 8 | vendor: Vendor; 9 | material: string; 10 | price: number; 11 | density: number; 12 | diameter: number; 13 | weight: number; 14 | spool_weight: number; 15 | article_number: string | null; 16 | comment: string | null; 17 | extruder_temp: number; 18 | bed_temp: number; 19 | color_hex: string; 20 | multi_color_hexes: string | null; 21 | multi_color_direction: string | null; 22 | external_id: string | null; 23 | extra: Record; 24 | } -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Entities/BambuServiceRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Gateways; 4 | 5 | public class BambuServiceRequest 6 | { 7 | [JsonPropertyName("entity_id")] 8 | public string[] EntityId { get; set; } 9 | [JsonPropertyName("tray_info_idx")] 10 | public string TrayInfoIdx { get; set; } 11 | [JsonPropertyName("tray_color")] 12 | public string TrayColor { get; set; } 13 | [JsonPropertyName("tray_type")] 14 | public string TrayType { get; set; } 15 | [JsonPropertyName("nozzle_temp_min")] 16 | public int NozzleTempMin { get; set; } 17 | [JsonPropertyName("nozzle_temp_max")] 18 | public int NozzleTempMax { get; set; } 19 | } 20 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/HealthTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using RichardSzalay.MockHttp; 3 | 4 | namespace Gateways.Tests 5 | { 6 | internal class HealthTests : EndpointTest 7 | { 8 | [Test] 9 | public async Task WhenCheckHealthAsync_TrueShouldBeReturned() 10 | { 11 | // Arrange & Act 12 | var result = await Endpoint.CheckHealthAsync(); 13 | 14 | // Assert 15 | result.Should().BeTrue(); 16 | } 17 | 18 | public override void SetupHttpClient(MockHttpMessageHandler mockHandler) 19 | { 20 | mockHandler 21 | .When("/api/v1/health") 22 | .Respond("application/json", ""); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /UI/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /UI/src/app/shared/service/tray.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpParams } from '@angular/common/http'; 2 | import { map, Observable } from 'rxjs'; 3 | import { AMSEntity, Tray } from '../models/tray'; 4 | import { Injectable } from '@angular/core'; 5 | import { environment } from '../../../environments/environment'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class TrayService { 11 | private baseUrl = environment.apiBaseUrl; // change if your API is prefixed 12 | 13 | constructor(private http: HttpClient) {} 14 | 15 | // Get Trays 16 | getTrays(): Observable<{ ams_entities: AMSEntity[], external_spool_entity: Tray }> { 17 | return this.http.get<{ ams_entities: AMSEntity[], external_spool_entity: Tray }>(`${this.baseUrl}trays`).pipe(map(response => response)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Gateways/LoggingHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | namespace Gateways; 4 | 5 | public class LoggingHandler : DelegatingHandler 6 | { 7 | private readonly ILogger _logger; 8 | 9 | public LoggingHandler(ILogger logger) 10 | { 11 | _logger = logger; 12 | } 13 | 14 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 15 | { 16 | _logger.LogInformation("HTTP Request: {Method} {Url}", request.Method, request.RequestUri); 17 | 18 | var response = await base.SendAsync(request, cancellationToken); 19 | 20 | _logger.LogInformation("HTTP Response: {StatusCode} for {Url}", response.StatusCode, request.RequestUri); 21 | 22 | return response; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Domain/Extensions/Type.cs: -------------------------------------------------------------------------------- 1 | namespace Domain; 2 | 3 | public static class TypeExtensions 4 | { 5 | public static bool Implements(this Type type, Type interfaceType) => 6 | interfaceType.IsGenericType 7 | ? type.ImplementsGenericInterface(interfaceType) 8 | : type.ImplementsInterface(interfaceType); 9 | 10 | private static bool ImplementsInterface(this Type type, Type interfaceType) => 11 | type 12 | .GetInterfaces() 13 | .Contains(interfaceType); 14 | 15 | private static bool ImplementsGenericInterface(this Type type, Type interfaceType) => 16 | type 17 | .GetInterfaces() 18 | .Where(typeInterfaceType => typeInterfaceType.IsGenericType) 19 | .Select(typeInterfaceType => typeInterfaceType.GetGenericTypeDefinition()) 20 | .Contains(interfaceType); 21 | } -------------------------------------------------------------------------------- /Application/Application.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | e09735fc-4dc9-4216-aba1-9c84b9b8ad4c 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Always 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Vendor.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | internal class VendorSpoolmanEndpoint(SpoolmanConfiguration configuration) : SpoolmanEndpoint(configuration), IVendorEndpoint 4 | { 5 | protected override string Endpoint => "vendor"; 6 | 7 | // Get or create a vendor 8 | public async Task GetOrCreate(string name) 9 | { 10 | var vendorResponse = await GetAllAsync($"name={name}"); 11 | 12 | Vendor? vendor; 13 | if (vendorResponse != null && vendorResponse.Any()) 14 | vendor = vendorResponse.First(); 15 | else 16 | { 17 | var newVendor = new Vendor 18 | { 19 | Name = name 20 | }; 21 | 22 | vendor = await PostAsync(newVendor); 23 | } 24 | 25 | return vendor ?? throw new InvalidOperationException("Failed to create or retrieve vendor."); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Application/Controllers/Spools.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Application.Controllers; 5 | 6 | [ApiController] 7 | [Route("[controller]")] 8 | public class SpoolsController(IInputHandler handler) : ControllerBase 9 | { 10 | [HttpPost] 11 | public async Task Update([FromBody] UpdateSpoolInput input) => 12 | Ok(await handler.HandleAsync(input)); 13 | 14 | [HttpPost("tray")] 15 | public async Task UpdateTray([FromBody] UpdateTrayInput input) => 16 | Ok(await handler.HandleAsync(input)); 17 | 18 | [HttpGet] 19 | public async Task Get([FromQuery] GetAllSpoolsInput input) => 20 | Ok(await handler.HandleAsync(input)); 21 | 22 | [HttpGet("barcode")] 23 | public async Task GetByBarcode([FromQuery] string barcode) => 24 | Ok(await handler.HandleAsync(new GetByBarcodeInput(barcode))); 25 | } 26 | -------------------------------------------------------------------------------- /Domain/UseCases/Spool/Tray/UseCase.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | 3 | namespace Domain; 4 | 5 | internal sealed class UpdateTrayUseCase(SpoolmanClient spoolmanClient) : IUseCase 6 | { 7 | public async Task ExecuteAsync(UpdateTrayInput input) 8 | { 9 | var currentSpools = await spoolmanClient.GetCurrentSpoolsInTray(input.ActiveTrayId); 10 | 11 | foreach(var currentSpool in currentSpools) 12 | { 13 | if (currentSpool.Id == input.SpoolId) 14 | continue; 15 | await spoolmanClient.SetActiveTray(currentSpool.Id.Value, string.Empty); 16 | } 17 | 18 | if (!await spoolmanClient.SetActiveTray(input.SpoolId, input.ActiveTrayId)) 19 | throw new InvalidOperationException($"Update Spool with ID {input.SpoolId} not successfull."); 20 | 21 | var spool = await spoolmanClient.GetByIdAsync(input.SpoolId); 22 | 23 | return new UpdateTrayOutput(spool); 24 | } 25 | } -------------------------------------------------------------------------------- /UI/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "useDefineForClassFields": false, 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /UI/src/app/components/spool/spool.component.html: -------------------------------------------------------------------------------- 1 |
2 | @for (ams of amsEntities | async; track ams; let index = $index) { 3 | 4 | 5 | AMS {{ index + 1 }} 6 | 7 | 8 |
9 | 10 | @for (tray of ams.trays; track tray; let index = $index) { 11 | 12 | 13 | } 14 | 15 |
16 |
17 |
18 | } 19 |
20 | 21 |
22 | 23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 |
31 |
-------------------------------------------------------------------------------- /UI/src/app/shared/components/spool/spool.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, Input } from '@angular/core'; 3 | import { Spool } from '../../models/spool'; 4 | 5 | @Component({ 6 | selector: 'app-spool-item', 7 | standalone: true, 8 | imports: [ 9 | CommonModule, 10 | ], 11 | templateUrl: './spool.component.html', 12 | styleUrls: ['./spool.component.scss'], 13 | }) 14 | export class SpoolItemComponent { 15 | @Input() spool: Spool | undefined; 16 | 17 | getTextColor(hexColor: string): string { 18 | if (!hexColor) return 'black'; // default 19 | 20 | // Parse r, g, b 21 | const r = parseInt(hexColor.substring(0, 2), 16); 22 | const g = parseInt(hexColor.substring(2, 4), 16); 23 | const b = parseInt(hexColor.substring(4, 6), 16); 24 | 25 | // Calculate brightness 26 | const brightness = (r * 299 + g * 587 + b * 114) / 1000; 27 | 28 | // Return black for light backgrounds, white for dark backgrounds 29 | return brightness > 150 ? 'black' : 'white'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/ISpoolmanEndpoint.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | public interface ISpoolmanEndpoint 4 | where TSpoolmanEntity : class 5 | { 6 | } 7 | 8 | public interface IVendorEndpoint 9 | { 10 | Task GetOrCreate(string name); 11 | } 12 | 13 | public interface IFilamentEndpoint 14 | { 15 | Task GetOrCreate(Vendor vendor, string color, string material); 16 | } 17 | 18 | public interface ISpoolEndpoint 19 | { 20 | Task GetOrCreateSpool(string vendorName, string material, string color, string activeTrayId, string tagUid); 21 | 22 | Task UseSpoolWeight(int spoolId, float usedWeight); 23 | 24 | Task SetActiveTray(int spoolId, string activeTrayId); 25 | 26 | Task> GetCurrentSpoolsInTray(string trayId); 27 | 28 | Task> GetAllAsync(); 29 | 30 | Task> GetSpoolsByBarcode(string barcode); 31 | 32 | Task GetByIdAsync(int spoolId); 33 | } 34 | 35 | public interface IFieldEndpoint 36 | { 37 | Task CheckFieldExistence(); 38 | } 39 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Entities/Spool.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | using System.Collections.Generic; 4 | 5 | public class Spool 6 | { 7 | public int? Id { get; set; } 8 | public string Registered { get; set; } 9 | public string FirstUsed { get; set; } 10 | public string LastUsed { get; set; } 11 | public int FilamentId { get; set; } 12 | public Filament Filament { get; set; } 13 | public decimal? Price { get; set; } 14 | public double RemainingWeight { get; set; } 15 | public double InitialWeight { get; set; } 16 | public double SpoolWeight { get; set; } 17 | public double? UsedWeight { get; set; } 18 | public double? RemainingLength { get; set; } 19 | public double UsedLength { get; set; } 20 | public string Location { get; set; } 21 | public string LotNumber { get; set; } 22 | public string Comment { get; set; } 23 | public bool Archived { get; set; } 24 | public Dictionary Extra { get; set; } 25 | 26 | public static bool IsEmptyTag(string tagUid) => string.IsNullOrEmpty(tagUid) || tagUid == "0000000000000000"; 27 | } 28 | -------------------------------------------------------------------------------- /UI/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore bin and obj folders (build artifacts) 2 | bin/ 3 | obj/ 4 | 5 | # Ignore user-specific files 6 | *.user 7 | *.vs 8 | .vscode/ 9 | 10 | # Ignore temporary files 11 | *.log 12 | *.tmp 13 | *.swp 14 | *.swo 15 | 16 | # Ignore local configuration files 17 | **/appsettings.Development.json 18 | secrets.json 19 | 20 | # Ignore Git and dependency caches 21 | .git/ 22 | .gitignore 23 | .nuget/ 24 | npm-debug.log 25 | yarn.lock 26 | node_modules/ 27 | **/node_modules 28 | **/dist 29 | 30 | # Ignore Docker files that shouldn’t be copied 31 | Dockerfile 32 | docker-compose.yml 33 | docker-compose.override.yml 34 | 35 | **/.classpath 36 | **/.dockerignore 37 | **/.env 38 | **/.git 39 | **/.gitignore 40 | **/.project 41 | **/.settings 42 | **/.toolstarget 43 | **/.vs 44 | **/.vscode 45 | **/*.*proj.user 46 | **/*.dbmdl 47 | **/*.jfm 48 | **/azds.yaml 49 | **/bin 50 | **/charts 51 | **/docker-compose* 52 | **/Dockerfile* 53 | **/node_modules 54 | **/npm-debug.log 55 | **/obj 56 | **/secrets.dev.yaml 57 | **/values.dev.yaml 58 | LICENSE 59 | README.md 60 | !**/.gitignore 61 | !.git/HEAD 62 | !.git/config 63 | !.git/packed-refs 64 | !.git/refs/heads/** -------------------------------------------------------------------------------- /UI/src/app/shared/service/spoolman.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpParams } from '@angular/common/http'; 3 | import { map, Observable } from 'rxjs'; 4 | import { UpdateTrayInput } from '../models/updatetray'; 5 | import { Spool } from '../models/spool'; 6 | import { environment } from '../../../environments/environment'; 7 | 8 | @Injectable({ 9 | providedIn: 'root', 10 | }) 11 | export class SpoolsService { 12 | private baseUrl = environment.apiBaseUrl; 13 | 14 | constructor(private http: HttpClient) {} 15 | 16 | /** 17 | * GET /Spools 18 | */ 19 | getSpools(): Observable { 20 | return this.http.get(`${this.baseUrl}spools`).pipe(map(response => response.spools)); 21 | } 22 | 23 | /** 24 | * POST /Spools/tray 25 | */ 26 | updateTray(data: UpdateTrayInput): Observable { 27 | return this.http.post(`${this.baseUrl}spools/tray`, data).pipe(map(response => response.spool)); 28 | } 29 | 30 | getByBarcode(barcode: string): Observable { 31 | const params = new HttpParams() 32 | .set('barcode', barcode); // Example barcode, replace with actual value 33 | 34 | return this.http.get(`${this.baseUrl}spools/barcode`, { params }).pipe(map(response => response.spools)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Gateways.Tests/Gateways.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | all 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /UI/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spoolman-updater", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "build:prod": "ng build spoolman-updater --configuration production", 10 | "test": "ng test" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^17.3.0", 15 | "@angular/cdk": "^17.3.10", 16 | "@angular/common": "^17.3.0", 17 | "@angular/compiler": "^17.3.0", 18 | "@angular/core": "^17.3.0", 19 | "@angular/forms": "^17.3.0", 20 | "@angular/material": "^17.3.10", 21 | "@angular/platform-browser": "^17.3.0", 22 | "@angular/platform-browser-dynamic": "^17.3.0", 23 | "@angular/router": "^17.3.0", 24 | "ngx-scanner-qrcode": "^1.7.3", 25 | "rxjs": "~7.8.0", 26 | "tslib": "^2.3.0", 27 | "zone.js": "~0.14.3" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "^17.3.8", 31 | "@angular/cli": "^17.3.8", 32 | "@angular/compiler-cli": "^17.3.0", 33 | "@types/jasmine": "~5.1.0", 34 | "jasmine-core": "~5.1.0", 35 | "karma": "~6.4.0", 36 | "karma-chrome-launcher": "~3.2.0", 37 | "karma-coverage": "~2.2.0", 38 | "karma-jasmine": "~5.1.0", 39 | "karma-jasmine-html-reporter": "~2.1.0", 40 | "typescript": "~5.4.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Client.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace Gateways; 4 | 5 | public class SpoolmanClient(IHealthEndpoint healthEndpoint, ISpoolEndpoint spoolEndpoint, IFieldEndpoint fieldEndpoint) 6 | { 7 | public async Task> GetAllAsync() => await spoolEndpoint.GetAllAsync(); 8 | 9 | public async Task> GetByBarcodeAsync(string barcode) => 10 | await spoolEndpoint.GetSpoolsByBarcode(barcode); 11 | 12 | public async Task UseSpoolWeightAsync(int spoolId, float usedWeight) => 13 | await spoolEndpoint.UseSpoolWeight(spoolId, usedWeight); 14 | 15 | public async Task GetSpoolByBrandAndColorAsync(string brand, string material, string color, string activeTrayId, string tagUid) => 16 | await spoolEndpoint.GetOrCreateSpool(brand, material, color, activeTrayId, tagUid); 17 | 18 | public async Task SetActiveTray(int spoolId, string activeTrayId) => 19 | await spoolEndpoint.SetActiveTray(spoolId, activeTrayId); 20 | 21 | public async Task> GetCurrentSpoolsInTray(string trayId) => 22 | await spoolEndpoint.GetCurrentSpoolsInTray(trayId); 23 | 24 | public async Task CheckHealthAsync() => 25 | await healthEndpoint.CheckHealthAsync(); 26 | 27 | public async Task CheckFieldExistence() => 28 | await fieldEndpoint.CheckFieldExistence(); 29 | 30 | public async Task GetByIdAsync(int spoolId) => await spoolEndpoint.GetByIdAsync(spoolId); 31 | } 32 | -------------------------------------------------------------------------------- /Application/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a build argument to determine the architecture (defaults to amd64) 2 | ARG TARGETARCH=amd64 3 | 4 | # Base image for running the app 5 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base 6 | USER $APP_UID 7 | WORKDIR /app 8 | EXPOSE 8080 9 | EXPOSE 8081 10 | 11 | # Build the Angular app 12 | FROM node:20-slim AS angular-build 13 | WORKDIR /src 14 | 15 | COPY UI/package*.json ./ 16 | COPY UI/angular.json ./ 17 | COPY UI/tsconfig*.json ./ 18 | 19 | # Install dependencies 20 | RUN npm install 21 | 22 | # Build the Angular app 23 | COPY UI/src ./src 24 | RUN npm run build:prod 25 | 26 | # Build the .NET application 27 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 28 | 29 | ARG BUILD_CONFIGURATION=Release 30 | WORKDIR /src 31 | COPY ["Application/Application.csproj", "Application/"] 32 | COPY ["Domain/Domain.csproj", "Domain/"] 33 | COPY ["Gateways/Gateways.csproj", "Gateways/"] 34 | RUN dotnet restore "./Application/Application.csproj" 35 | COPY . . 36 | WORKDIR "/src/Application" 37 | RUN dotnet build "./Application.csproj" -c $BUILD_CONFIGURATION -o /app/build 38 | 39 | # Publish the .NET application 40 | FROM build AS publish 41 | ARG BUILD_CONFIGURATION=Release 42 | RUN dotnet publish "./Application.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 43 | 44 | # Final image for running the app 45 | FROM base AS final 46 | WORKDIR /app 47 | COPY --from=publish /app/publish . 48 | COPY --from=angular-build /src/dist/spoolman-updater/browser ./wwwroot 49 | ENTRYPOINT ["dotnet", "Application.dll"] 50 | -------------------------------------------------------------------------------- /Application/Program.cs: -------------------------------------------------------------------------------- 1 | using Domain; 2 | using Gateways; 3 | using Microsoft.AspNetCore.Mvc; 4 | using System.Text.Json; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | // Add services to the container. 9 | 10 | builder.Services.AddControllers(); 11 | 12 | builder.Services.AddEndpointsApiExplorer(); 13 | builder.Services.AddSwaggerGen(); 14 | builder.Services.AddCors(options => 15 | { 16 | options.AddDefaultPolicy(policy => 17 | { 18 | policy 19 | .AllowAnyOrigin() 20 | .AllowAnyMethod() 21 | .AllowAnyHeader(); 22 | }); 23 | }); 24 | 25 | var configuration = builder.Configuration.GetSection("Application").Get(); 26 | 27 | builder.Services 28 | .AddDomain() 29 | .AddGateways(configuration); 30 | 31 | builder.Services.Configure(options => 32 | { 33 | options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; 34 | }); 35 | 36 | var app = builder.Build(); 37 | 38 | // Configure the HTTP request pipeline. 39 | app.UseSwagger(); 40 | app.UseSwaggerUI(); 41 | 42 | app.UseCors(); 43 | app.UseAuthorization(); 44 | 45 | app.UseDefaultFiles(); 46 | app.UseStaticFiles(); 47 | 48 | app.MapControllers(); 49 | 50 | app.Lifetime.ApplicationStarted.Register(async () => 51 | { 52 | using var scope = app.Services.CreateScope(); 53 | var gatewayChecker = scope.ServiceProvider.GetRequiredService(); 54 | 55 | await gatewayChecker.CheckGatewayConnectionAsync(); 56 | }); 57 | 58 | app.Run(); 59 | -------------------------------------------------------------------------------- /Application/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:5252" 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | }, 20 | "dotnetRunMessages": true, 21 | "applicationUrl": "https://localhost:7042;http://localhost:5252" 22 | }, 23 | "IIS Express": { 24 | "commandName": "IISExpress", 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | }, 31 | "Container (Dockerfile)": { 32 | "commandName": "Docker", 33 | "launchBrowser": true, 34 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", 35 | "environmentVariables": { 36 | "ASPNETCORE_HTTPS_PORTS": "8081", 37 | "ASPNETCORE_HTTP_PORTS": "8080" 38 | }, 39 | "publishAllPorts": true, 40 | "useSSL": true 41 | } 42 | }, 43 | "$schema": "http://json.schemastore.org/launchsettings.json", 44 | "iisSettings": { 45 | "windowsAuthentication": false, 46 | "anonymousAuthentication": true, 47 | "iisExpress": { 48 | "applicationUrl": "http://localhost:13077", 49 | "sslPort": 44323 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /UI/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Host, HostBinding, ViewChild, ViewEncapsulation } from '@angular/core'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatIconModule } from '@angular/material/icon'; 5 | import { SpoolsService } from './shared/service/spoolman.service'; 6 | import { HttpClientModule } from '@angular/common/http'; 7 | import { TrayService } from './shared/service/tray.service'; 8 | import { CommonModule } from '@angular/common'; 9 | import { Router, RouterModule } from '@angular/router'; 10 | import { CameraScanComponent } from './shared/components/scan/scan.component'; 11 | 12 | @Component({ 13 | selector: 'app-root', 14 | standalone: true, 15 | imports: [ 16 | HttpClientModule, 17 | CommonModule, 18 | MatToolbarModule, 19 | RouterModule, 20 | MatButtonModule, 21 | MatIconModule, 22 | CameraScanComponent, 23 | ], 24 | templateUrl: './app.component.html', 25 | styleUrls: ['./app.component.scss'], 26 | providers: [SpoolsService, TrayService], 27 | }) 28 | export class AppComponent { 29 | @ViewChild(CameraScanComponent) 30 | scanComponent!: CameraScanComponent; 31 | 32 | scanning = false; 33 | 34 | constructor(private router: Router) {} 35 | 36 | openScanner() { 37 | this.scanning = !this.scanning; 38 | 39 | if (this.scanning) 40 | this.scanComponent.startScanning(); 41 | else 42 | this.scanComponent.stopScanning(); 43 | } 44 | 45 | goToScan(barcode: string) { 46 | this.scanning = false; 47 | this.router.navigate(['/scan'], { queryParams: { barcode: barcode } }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Domain/Extensions/ServiceCollection.cs: -------------------------------------------------------------------------------- 1 | using Gateways; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using System.Reflection; 4 | 5 | namespace Domain; 6 | 7 | public static class ServiceCollectionExtensions 8 | { 9 | public static IServiceCollection AddDomain(this IServiceCollection services, Assembly domainAssembly = null, ServiceLifetime serviceLifetime = ServiceLifetime.Scoped) => 10 | services 11 | .AddServices(typeof(IUseCase<>), domainAssembly, serviceLifetime) 12 | .AddScoped(); 13 | 14 | public static IServiceCollection AddServices(this IServiceCollection services, Type serviceInterfaceType, Assembly scanAssembly = null, ServiceLifetime serviceLifetime = ServiceLifetime.Scoped) 15 | { 16 | scanAssembly ??= Assembly.GetCallingAssembly(); 17 | 18 | foreach (var implementationType in scanAssembly.GetImplementations(serviceInterfaceType)) 19 | { 20 | foreach (var serviceType in implementationType.GetInterfaces()) 21 | { 22 | services.Add(new ServiceDescriptor(serviceType, implementationType, serviceLifetime)); 23 | } 24 | } 25 | 26 | return services; 27 | } 28 | 29 | public static IServiceCollection AddGateways(this IServiceCollection services, UpdaterConfiguration configuration) => 30 | services 31 | .AddServices(typeof(ISpoolmanEndpoint<>), typeof(SpoolmanClient).Assembly, ServiceLifetime.Scoped) 32 | .AddSingleton(configuration.Spoolman) 33 | .AddSingleton(configuration.HomeAssistant) 34 | .AddScoped() 35 | .AddScoped() 36 | .AddScoped() 37 | .AddTransient(); 38 | } 39 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Entities/Filament.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | 4 | using System.Collections.Generic; 5 | using System.Drawing; 6 | 7 | public class Filament 8 | { 9 | public int Id { get; set; } 10 | public string Registered { get; set; } 11 | public string Name { get; set; } 12 | public int VendorId { get; set; } 13 | public Vendor Vendor { get; set; } 14 | public string Material { get; set; } 15 | public decimal Price { get; set; } 16 | public double Density { get; set; } 17 | public double Diameter { get; set; } 18 | public double Weight { get; set; } 19 | public double SpoolWeight { get; set; } 20 | public string ArticleNumber { get; set; } 21 | public string Comment { get; set; } 22 | public int ExtruderTemp { get; set; } 23 | public int BedTemp { get; set; } 24 | public string ColorHex { get; set; } 25 | public string MultiColorHexes { get; set; } 26 | public string MultiColorDirection { get; set; } 27 | public string ExternalId { get; set; } 28 | public Dictionary Extra { get; set; } 29 | 30 | public static string GetNearestColorName(string hex) 31 | { 32 | Color target = ColorTranslator.FromHtml(hex); 33 | 34 | return typeof(Color).GetProperties() 35 | .Where(p => p.PropertyType == typeof(Color)) 36 | .Select(p => (Color)p.GetValue(null)) 37 | .Where(c => c.A == 255) // Exclude transparent colors 38 | .OrderBy(c => GetColorDistance(target, c)) 39 | .First().Name; 40 | } 41 | 42 | private static double GetColorDistance(Color c1, Color c2) 43 | { 44 | return Math.Sqrt( 45 | Math.Pow(c1.R - c2.R, 2) + 46 | Math.Pow(c1.G - c2.G, 2) + 47 | Math.Pow(c1.B - c2.B, 2) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Filament.cs: -------------------------------------------------------------------------------- 1 | namespace Gateways; 2 | 3 | internal class FilamentSpoolManEndpoint(SpoolmanConfiguration configuration) : SpoolmanEndpoint(configuration), IFilamentEndpoint 4 | { 5 | protected override string Endpoint => "filament"; 6 | 7 | // Get or create a vendor 8 | public async Task GetOrCreate(Vendor vendor, string color, string material) 9 | { 10 | Filament? filament = await GetFilament(vendor.Name, color, material); 11 | 12 | filament ??= await CreateFilament(color, material, vendor); 13 | 14 | return filament ?? throw new InvalidOperationException("Failed to create or retrieve filament."); 15 | } 16 | 17 | private async Task CreateFilament(string color, string material, Vendor vendor) 18 | { 19 | var newFilament = new Filament 20 | { 21 | Name = Filament.GetNearestColorName($"#{color}"), // Default name, adjust as needed 22 | VendorId = vendor.Id.Value, 23 | ColorHex = color, 24 | Material = material, // Default material, adjust as needed 25 | Diameter = 1.75, 26 | Density = 1.24, 27 | Weight = 1000 28 | }; 29 | 30 | return await PostAsync(newFilament); 31 | } 32 | 33 | private async Task GetFilament(string vendorName, string color, string material) 34 | { 35 | var filaments = await GetAllAsync($"{FilamentQueryConstants.VendorName}={vendorName}&{FilamentQueryConstants.ColorHex}={color}&{FilamentQueryConstants.Material}={material}"); 36 | 37 | Filament? filament = null; 38 | if (filaments != null && filaments.Any()) 39 | filament = filaments.FirstOrDefault(filament => filament.ColorHex == color); 40 | 41 | return filament; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /UI/src/app/components/spool/spool.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe } from '@angular/common'; 2 | import { Component, HostBinding, ViewEncapsulation } from '@angular/core'; 3 | import { MatCardModule } from '@angular/material/card'; 4 | import { BehaviorSubject, forkJoin } from 'rxjs'; 5 | import { TrayComponent } from '../../shared/components/tray/tray.component'; 6 | import { Spool } from '../../shared/models/spool'; 7 | import { AMSEntity, Tray } from '../../shared/models/tray'; 8 | import { SpoolsService } from '../../shared/service/spoolman.service'; 9 | import { TrayService } from '../../shared/service/tray.service'; 10 | 11 | @Component({ 12 | selector: 'spool-component', 13 | standalone: true, 14 | imports: [ 15 | MatCardModule, 16 | TrayComponent, 17 | AsyncPipe 18 | ], 19 | templateUrl: './spool.component.html', 20 | styleUrl: './spool.component.scss', 21 | providers: [SpoolsService, TrayService], 22 | encapsulation: ViewEncapsulation.None, 23 | }) 24 | export class SpoolComponent { 25 | @HostBinding('class') className = 'spool-component'; 26 | 27 | spools: Spool[] = []; 28 | 29 | 30 | externalSpoolEntity$ = new BehaviorSubject({} as Tray); 31 | amsEntities$ = new BehaviorSubject([]); 32 | 33 | amsEntities = this.amsEntities$.asObservable(); 34 | externalSpoolEntity = this.externalSpoolEntity$.asObservable(); 35 | 36 | constructor( 37 | private spoolService: SpoolsService, 38 | private trayService: TrayService 39 | ) { 40 | 41 | forkJoin({ 42 | spools: this.spoolService.getSpools(), 43 | trays: this.trayService.getTrays() 44 | }).subscribe(({ spools, trays }) => { 45 | this.spools = spools; 46 | this.amsEntities$.next(trays.ams_entities); 47 | this.externalSpoolEntity$.next(trays.external_spool_entity); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/VendorTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using RichardSzalay.MockHttp; 3 | 4 | namespace Gateways.Tests 5 | { 6 | internal class VendorTests : EndpointTest 7 | { 8 | [Test] 9 | public async Task GivenNonExistingVendorName_WhenGetOrCreate_CreatedVendorShouldBeReturned() 10 | { 11 | // Arrange & Act 12 | var result = await Endpoint.GetOrCreate("Vendor2"); 13 | 14 | // Assert 15 | result.Should().NotBeNull(); 16 | result.Name.Should().Be("Vendor2"); 17 | } 18 | 19 | [Test] 20 | public async Task GivenExistingVendorName_WhenGetOrCreate_ExistingVendorShouldBeReturned() 21 | { 22 | // Arrang e& Act 23 | var result = await Endpoint.GetOrCreate("Vendor1"); 24 | 25 | // Assert 26 | result.Should().NotBeNull(); 27 | result.Name.Should().Be("Vendor1"); 28 | } 29 | 30 | public override void SetupHttpClient(MockHttpMessageHandler mockHandler) 31 | { 32 | mockHandler 33 | .When("/api/v1/vendor") 34 | .WithQueryString("name", "Vendor1") 35 | .Respond("application/json", "[{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor\",\"extra\":{}}]"); 36 | 37 | mockHandler 38 | .When("/api/v1/vendor") 39 | .WithQueryString("name", "Vendor2") 40 | .Respond("application/json", "[]"); 41 | 42 | mockHandler.When("/api/v1/vendor") 43 | .WithContent("{\"name\":\"Vendor2\"}") 44 | .Respond("application/json", "{\"id\":2,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor2\",\"external_id\":\"Vendor2\",\"extra\":{}}"); 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /unraid_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Spoolman Updater 4 | marcokreeft/spoolman-updater:latest 5 | bridge 6 | 7 | false 8 | https://hub.docker.com/repository/docker/marcokreeft/spoolman-updater 9 | Automatically updates spool information in Spoolman based on Home Assistant data. 10 | Tools:Automation 11 | http://[IP]:[PORT:8080]/swagger 12 | https://raw.githubusercontent.com/marcokreeft/spoolman-updater/main/unraid_template.xml 13 | https://hub.docker.com/public/images/logos/mini-logo.png 14 | 15 | 16 | APPLICATION__HOMEASSISTANT__URL 17 | http://homeassistant.local 18 | 19 | 20 | APPLICATION__HOMEASSISTANT__TRAYSENSORPREFIX 21 | sensor.x1c_tray_ 22 | 23 | 24 | APPLICATION__HOMEASSISTANT__TOKEN 25 | your-token 26 | hidden 27 | 28 | 29 | APPLICATION__SPOOLMAN__URL 30 | http://spoolman.local 31 | 32 | 33 | 34 | 35 | 8080 36 | 8080 37 | tcp 38 | 39 | 40 | 41 | 42 | /mnt/user/appdata/spoolman-updater 43 | /app/config 44 | rw 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /UI/src/app/components/spool/spool.component.scss: -------------------------------------------------------------------------------- 1 | .spool-component { 2 | .ams-container { 3 | display: flex; 4 | flex-wrap: wrap; 5 | gap: 16px; 6 | justify-content: flex-start; 7 | flex-direction: column; 8 | padding: 24px; 9 | 10 | .ams-card { 11 | color: white; 12 | background-color: gray; 13 | flex: 1; 14 | display: flex; 15 | 16 | .tray-container { 17 | flex: 1; 18 | display: flex; 19 | flex-direction: row; 20 | gap: 16px; 21 | padding: 16px; 22 | 23 | .tray-card { 24 | flex: 1 1 calc(33.333% - 16px); 25 | box-sizing: border-box; 26 | min-width: 250px; 27 | max-width: 400px; 28 | color: white; 29 | 30 | .mat-mdc-card-header { 31 | border-bottom: 1px solid gray; 32 | padding: 16px; 33 | } 34 | 35 | .trays { 36 | display: flex; 37 | justify-content: center; 38 | align-items: center; 39 | gap: 10px; 40 | padding: 16px; 41 | } 42 | 43 | .mat-mdc-card-actions { 44 | button { 45 | width: 100%; 46 | } 47 | } 48 | 49 | .mdc-list-item { 50 | padding: 8px; 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | @media (width <= 1250px) { 58 | .ams-container { 59 | .ams-card { 60 | .tray-container { 61 | flex-direction: column; 62 | gap: 16px; 63 | } 64 | } 65 | } 66 | } 67 | 68 | 69 | } 70 | 71 | .cdk-overlay-container { 72 | .spool-option { 73 | display: flex; 74 | gap: 8px; 75 | padding: 8px; 76 | border-bottom: 1px solid grey; 77 | 78 | .spool-color { 79 | width: 25px; 80 | height: 25px; 81 | } 82 | 83 | .mat-divider { 84 | .mat-divider-inset { 85 | margin-left: 0px !important; 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /UI/src/app/components/scan/scan.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Select a tray

11 |
12 |
13 | 14 | 15 |
16 | 17 | @for (ams of amsEntities; track ams; let index = $index) { 18 | 19 | 20 | 21 | AMS {{ index + 1 }} 22 | 23 | 24 | @for (tray of ams.trays; track tray; let trayIndex = $index) { 25 | 26 | 33 | } 34 | 35 | 36 | 37 | } 38 | 39 | 40 | 41 |

No AMS found

42 |
43 | 44 | 45 | 46 | 47 | External Spool 48 | 49 | 50 | 53 | 54 | 55 | 56 | 57 | 58 |

No External Spool found

59 |
60 |
61 |
62 |
63 | -------------------------------------------------------------------------------- /UI/src/app/components/scan/scan.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { SpoolsService } from '../../shared/service/spoolman.service'; 3 | import { TrayService } from '../../shared/service/tray.service'; 4 | import { Spool } from '../../shared/models/spool'; 5 | import { CommonModule } from '@angular/common'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatButtonModule } from '@angular/material/button'; 8 | import { AMSEntity, Tray } from '../../shared/models/tray'; 9 | import { ActivatedRoute, Router } from '@angular/router'; 10 | import { SpoolItemComponent } from '../../shared/components/spool/spool.component'; 11 | import { forkJoin } from 'rxjs'; 12 | 13 | @Component({ 14 | selector: 'app-scan', 15 | standalone: true, 16 | imports: [CommonModule, MatCardModule, MatButtonModule, SpoolItemComponent], 17 | templateUrl: './scan.component.html', 18 | styleUrl: './scan.component.scss', 19 | providers: [SpoolsService, TrayService], 20 | }) 21 | export class ScanComponent { 22 | spool: Spool | undefined; 23 | amsEntities: AMSEntity[] = []; 24 | externalSpoolEntity: Tray = {} as Tray; 25 | 26 | constructor( 27 | private route: ActivatedRoute, 28 | private spoolService: SpoolsService, 29 | private router: Router, 30 | trayService: TrayService 31 | ) { 32 | 33 | this.route.queryParamMap.subscribe(params => { 34 | const barcode = params.get('barcode') ?? ''; 35 | 36 | forkJoin({ 37 | spool: this.spoolService.getByBarcode(barcode), 38 | trays: trayService.getTrays() 39 | }).subscribe(({ spool, trays }) => { 40 | this.spool = spool[0]; 41 | this.amsEntities = trays.ams_entities; 42 | this.externalSpoolEntity = trays.external_spool_entity; 43 | }); 44 | }); 45 | } 46 | 47 | updateTray(tray: Tray | undefined): void { 48 | if (!tray || !this.spool) 49 | return; 50 | 51 | this.spoolService 52 | .updateTray({ 53 | spool_id: this.spool.id.toString(), 54 | active_tray_id: tray.id, 55 | }) 56 | .subscribe(_ => this.router.navigate(['/'])); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Base.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Gateways; 6 | 7 | internal abstract class SpoolmanEndpoint : ISpoolmanEndpoint 8 | where TSpoolmanEntity : class 9 | { 10 | protected readonly HttpClient HttpClient; 11 | protected readonly JsonSerializerOptions JsonOptions; 12 | 13 | protected abstract string Endpoint { get; } 14 | 15 | public SpoolmanEndpoint(SpoolmanConfiguration configuration) 16 | { 17 | HttpClient = new HttpClient(); 18 | HttpClient.BaseAddress = new Uri($"{configuration.Url}/api/v1/"); 19 | 20 | // Configure snake_case naming policy 21 | JsonOptions = new JsonSerializerOptions 22 | { 23 | PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, 24 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull 25 | }; 26 | } 27 | 28 | public async Task?> GetAllAsync(string query = "", bool useQueryParams = true) => 29 | await HttpClient.GetFromJsonAsync>($"{Endpoint}{(useQueryParams ? "?" : string.Empty)}{query}", JsonOptions); 30 | 31 | public async Task GetByIdAsync(string id) 32 | { 33 | var response = await HttpClient.GetAsync($"{Endpoint}/{id}"); 34 | 35 | return response.IsSuccessStatusCode ? await response.Content.ReadFromJsonAsync(JsonOptions) : null; 36 | } 37 | 38 | public async Task PostAsync(TSpoolmanEntity newEntity) 39 | { 40 | var json = JsonSerializer.Serialize(newEntity, JsonOptions); 41 | var createVendorResponse = await HttpClient.PostAsJsonAsync(Endpoint, newEntity, JsonOptions); 42 | 43 | return createVendorResponse.IsSuccessStatusCode ? await createVendorResponse.Content.ReadFromJsonAsync() : null; 44 | } 45 | 46 | public async Task UpdateAsync(int id, object patch) 47 | { 48 | var updateVendorResponse = await HttpClient.PatchAsJsonAsync($"{Endpoint}/{id}", patch, JsonOptions); 49 | return updateVendorResponse.IsSuccessStatusCode; 50 | } 51 | } -------------------------------------------------------------------------------- /UI/src/app/shared/components/tray/tray.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ name }} 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | Select a spool 12 | 13 | 19 | 20 | 21 | @for (spool of filteredSpools | async; track spool) { 22 | 23 |
24 |
28 |
{{ displaySpoolName(spool) }}
29 |
30 |
31 | } 32 |
33 | 34 | 35 | 36 | 52 |
53 |
54 |
55 | 56 | 59 | 60 |
61 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/EndpointTest.cs: -------------------------------------------------------------------------------- 1 | using RichardSzalay.MockHttp; 2 | using System.Reflection; 3 | 4 | namespace Gateways.Tests 5 | { 6 | internal class EndpointTest where TSpoolmanEndpoint : class 7 | { 8 | protected HttpClient HttpClient; 9 | protected TSpoolmanEndpoint Endpoint; 10 | protected object[] ConstructorArguments; 11 | 12 | [SetUp] 13 | public void Setup() 14 | { 15 | var mockHandler = new MockHttpMessageHandler(); 16 | 17 | SetupHttpClient(mockHandler); 18 | 19 | HttpClient = mockHandler.ToHttpClient(); 20 | HttpClient.BaseAddress = new Uri($"http://localhost:8080/api/v1/"); 21 | 22 | SetupConstructorArguments(); 23 | 24 | var constructorArguments = new object[] { new SpoolmanConfiguration() { Url = "http://localhost:8080" } }; 25 | 26 | if (ConstructorArguments?.Length > 0) 27 | constructorArguments = constructorArguments.Concat(ConstructorArguments).ToArray(); 28 | 29 | var types = constructorArguments.Select(argument => argument.GetType()).ToArray(); 30 | 31 | Endpoint = (TSpoolmanEndpoint)typeof(TSpoolmanEndpoint) 32 | .GetConstructor(types)? 33 | .Invoke(constructorArguments); 34 | 35 | SetHttpClientProperty(Endpoint); 36 | } 37 | 38 | protected TSpoolmanEndpoint SetHttpClientProperty(TSpoolmanEndpoint endpoint) 39 | where TSpoolmanEndpoint : class 40 | { 41 | var field = endpoint.GetType().GetField("HttpClient", BindingFlags.NonPublic | BindingFlags.Instance); 42 | 43 | typeof(FieldInfo).GetField("m_flags", BindingFlags.NonPublic | BindingFlags.Instance)? 44 | .SetValue(field, (int)field.Attributes & ~(int)FieldAttributes.InitOnly); 45 | 46 | // Set a new value for the field 47 | field?.SetValue(endpoint, HttpClient); 48 | 49 | return endpoint; 50 | } 51 | 52 | public virtual void SetupConstructorArguments() 53 | { 54 | } 55 | 56 | public virtual void SetupHttpClient(MockHttpMessageHandler mockHandler) 57 | { 58 | } 59 | 60 | [TearDown] 61 | public void TearDown() 62 | { 63 | HttpClient.Dispose(); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Field.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | 3 | namespace Gateways; 4 | 5 | internal class FieldSpoolmanEndpoint(SpoolmanConfiguration configuration) : SpoolmanEndpoint(configuration), IFieldEndpoint 6 | { 7 | private EntityType FieldType { get; set; } = EntityType.Spool; 8 | 9 | protected override string Endpoint => "field"; 10 | 11 | public async Task CheckFieldExistence() 12 | { 13 | var tasks = new[] 14 | { 15 | GetFieldAsync(FieldType, "tag"), 16 | GetFieldAsync(FieldType, "active_tray"), 17 | GetFieldAsync(FieldType, "barcode"), 18 | }; 19 | 20 | var results = await Task.WhenAll(tasks); 21 | 22 | // Return true if any of the results are not null 23 | return results.Any(field => field != null); 24 | } 25 | 26 | private async Task GetFieldAsync(EntityType fieldType, string key) 27 | { 28 | Field? field = null; 29 | var fields = await GetFieldsAsync(fieldType); 30 | 31 | field = fields?.FirstOrDefault(f => f.Key == key); 32 | 33 | if (fields != null && fields.Any()) 34 | { 35 | field = fields.FirstOrDefault(field => field.Key.ToLower() == key.ToLower()); 36 | } 37 | 38 | if (field == null) 39 | { 40 | var newFields = await CreateField(fieldType, key); 41 | 42 | field = newFields.FirstOrDefault(field => field.Key.ToLower() == key.ToLower()); 43 | } 44 | 45 | return field; 46 | } 47 | 48 | private async Task> CreateField(EntityType fieldType, string key) 49 | { 50 | var field = new Field 51 | { 52 | Key = key, 53 | EntityType = fieldType.ToString(), 54 | Name = key, 55 | FieldType = "text" 56 | }; 57 | 58 | return await PostAsync($"/{fieldType.ToString().ToLower()}/{key}", field) ?? new List(); 59 | } 60 | 61 | private async Task?> GetFieldsAsync(EntityType fieldType) => 62 | await GetAllAsync($"/{fieldType.ToString().ToLower()}", false); 63 | 64 | private async Task?> PostAsync(string extraEndpointPath, Field newEntity) 65 | { 66 | var createVendorResponse = await HttpClient.PostAsJsonAsync($"{Endpoint}{extraEndpointPath}", newEntity, JsonOptions); 67 | 68 | return createVendorResponse.IsSuccessStatusCode ? await createVendorResponse.Content.ReadFromJsonAsync>() : null; 69 | } 70 | } -------------------------------------------------------------------------------- /SpoolmanUpdater.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.13.35806.99 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{BCF03506-D0BC-42BA-A0EE-CE1E4D964E25}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateways", "Gateways\Gateways.csproj", "{DC155AA7-E20D-4D7E-958A-447A95EFABC2}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{D6B368CA-2CBE-4C71-9AB4-0A9FD6F43B3F}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gateways.Tests", "Gateways.Tests\Gateways.Tests.csproj", "{8B8CC01A-B675-4461-B71F-2DBC195FB64D}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {BCF03506-D0BC-42BA-A0EE-CE1E4D964E25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {BCF03506-D0BC-42BA-A0EE-CE1E4D964E25}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {BCF03506-D0BC-42BA-A0EE-CE1E4D964E25}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {BCF03506-D0BC-42BA-A0EE-CE1E4D964E25}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {DC155AA7-E20D-4D7E-958A-447A95EFABC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {DC155AA7-E20D-4D7E-958A-447A95EFABC2}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {DC155AA7-E20D-4D7E-958A-447A95EFABC2}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {DC155AA7-E20D-4D7E-958A-447A95EFABC2}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {D6B368CA-2CBE-4C71-9AB4-0A9FD6F43B3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {D6B368CA-2CBE-4C71-9AB4-0A9FD6F43B3F}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {D6B368CA-2CBE-4C71-9AB4-0A9FD6F43B3F}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {D6B368CA-2CBE-4C71-9AB4-0A9FD6F43B3F}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {8B8CC01A-B675-4461-B71F-2DBC195FB64D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {8B8CC01A-B675-4461-B71F-2DBC195FB64D}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {8B8CC01A-B675-4461-B71F-2DBC195FB64D}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {8B8CC01A-B675-4461-B71F-2DBC195FB64D}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {747EB4EF-1499-4B5B-A12F-AFECC833459B} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | paths-ignore: 7 | - 'README.md' 8 | - 'image.png' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-and-push: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up .NET 20 | uses: actions/setup-dotnet@v3 21 | with: 22 | dotnet-version: '8.0.x' 23 | 24 | - name: Restore dependencies 25 | run: dotnet restore 26 | 27 | - name: Run unit tests and generate report 28 | run: dotnet test --logger "trx;LogFileName=test_results.trx" --results-directory ./TestResults 29 | 30 | - name: Upload test results 31 | uses: dorny/test-reporter@v1 32 | with: 33 | name: Test Results 34 | path: ./TestResults/test_results.trx 35 | reporter: dotnet-trx 36 | 37 | - name: Log in to Docker Hub 38 | uses: docker/login-action@v3 39 | with: 40 | username: ${{ secrets.DOCKERHUB_USERNAME }} 41 | password: ${{ secrets.DOCKERHUB_TOKEN }} 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v3 45 | with: 46 | install: true 47 | 48 | - name: Set environment variable with date 49 | run: echo "DATE_TAG=$(date +'%Y%m%d%H%M')" >> $GITHUB_ENV 50 | 51 | - name: Build and push multi-platform Docker image 52 | uses: docker/build-push-action@v5 53 | with: 54 | context: . 55 | file: ./Application/Dockerfile 56 | push: true 57 | platforms: linux/amd64,linux/arm64 58 | tags: | 59 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}:latest 60 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}:${{ env.DATE_TAG }} 61 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}:${{ github.sha }} 62 | 63 | - name: Generate Release Notes 64 | run: | 65 | echo "## Changes in ${{ env.DATE_TAG }}" > RELEASE_NOTES.md 66 | echo "" >> RELEASE_NOTES.md 67 | git log --pretty=format:"- %s" -n 10 >> RELEASE_NOTES.md 68 | cat RELEASE_NOTES.md 69 | 70 | - name: Create GitHub Release 71 | uses: softprops/action-gh-release@v2 72 | with: 73 | tag_name: ${{ env.DATE_TAG }} 74 | name: Release ${{ env.DATE_TAG }} 75 | body_path: RELEASE_NOTES.md 76 | draft: false 77 | prerelease: false 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/FieldTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using RichardSzalay.MockHttp; 3 | using System.Reflection; 4 | 5 | namespace Gateways.Tests 6 | { 7 | internal class FieldTests : EndpointTest 8 | { 9 | [Test] 10 | public async Task GivenExistingField_WhenGetOrCreate_ExistingFieldShouldBeReturned() 11 | { 12 | // Arrange 13 | Endpoint.GetType().GetProperty("FieldType", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(Endpoint, EntityType.Spool); 14 | 15 | // Arrange 16 | var result = await Endpoint.CheckFieldExistence(); 17 | 18 | // Assert 19 | result.Should().BeTrue(); 20 | } 21 | 22 | [Test] 23 | public async Task GivenNonExistingField_WhenGetOrCreate_CreatedFieldShouldBeReturned() 24 | { 25 | // Arrange 26 | Endpoint.GetType().GetProperty("FieldType", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(Endpoint, EntityType.Filament); 27 | 28 | // Arrange 29 | var result = await Endpoint.CheckFieldExistence(); 30 | 31 | // Assert 32 | result.Should().BeTrue(); 33 | } 34 | 35 | public override void SetupHttpClient(MockHttpMessageHandler mockHandler) 36 | { 37 | mockHandler 38 | .When("/api/v1/field/spool") 39 | .Respond("application/json", "[{\"name\":\"tag\",\"order\":0,\"field_type\":\"text\",\"key\":\"tag\",\"entity_type\":\"spool\"},{\"name\":\"Active Tray\",\"order\":1,\"field_type\":\"text\",\"default_value\":\"\\\"\\\"\",\"key\":\"active_tray\",\"entity_type\":\"spool\"},{\"name\":\"barcode\",\"order\":0,\"field_type\":\"text\",\"key\":\"barcode\",\"entity_type\":\"spool\"}]"); 40 | 41 | mockHandler 42 | .When("/api/v1/field/filament") 43 | .Respond("application/json", "[]"); 44 | 45 | mockHandler 46 | .When(HttpMethod.Post, "/api/v1/field/filament/barcode") 47 | .Respond("application/json", "[{\"name\":\"barcode\",\"order\":0,\"field_type\":\"text\",\"key\":\"barcode\",\"entity_type\":\"filament\"}]"); 48 | 49 | mockHandler 50 | .When(HttpMethod.Post, "/api/v1/field/filament/active_tray") 51 | .Respond("application/json", "[{\"name\":\"active_tray\",\"order\":0,\"field_type\":\"text\",\"key\":\"active_tray\",\"entity_type\":\"filament\"}]"); 52 | 53 | mockHandler 54 | .When(HttpMethod.Post, "/api/v1/field/filament/tag") 55 | .Respond("application/json", "[{\"name\":\"tag\",\"order\":0,\"field_type\":\"text\",\"key\":\"tag\",\"entity_type\":\"filament\"}]"); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /UI/src/app/shared/components/scan/scan.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Output, 5 | ViewChild, 6 | } from '@angular/core'; 7 | import { CommonModule } from '@angular/common'; 8 | import { 9 | NgxScannerQrcodeModule, 10 | NgxScannerQrcodeComponent, 11 | } from 'ngx-scanner-qrcode'; 12 | import { MatSelectModule } from '@angular/material/select'; 13 | 14 | @Component({ 15 | selector: 'app-camera-scan', 16 | standalone: true, 17 | imports: [CommonModule, NgxScannerQrcodeModule, MatSelectModule], 18 | templateUrl: './scan.component.html', 19 | styleUrls: ['./scan.component.scss'], 20 | }) 21 | export class CameraScanComponent { 22 | @Output() scanningComplete = new EventEmitter(); 23 | 24 | @ViewChild(NgxScannerQrcodeComponent) 25 | barcodeScanner!: NgxScannerQrcodeComponent; 26 | 27 | barcodeValue: string = ''; 28 | scanning: boolean = false; 29 | 30 | cameraId: string | null = null; 31 | backCameras: MediaDeviceInfo[] = []; 32 | 33 | async ngAfterViewInit() { 34 | const devices = await navigator.mediaDevices.enumerateDevices(); 35 | const videoDevices = devices.filter( 36 | (device) => device.kind === 'videoinput' 37 | ); 38 | 39 | // Find cameras that are probably back-facing 40 | this.backCameras = videoDevices.filter( 41 | (device) => 42 | device.label.toLowerCase().includes('back') || 43 | device.label.toLowerCase().includes('rear') || 44 | device.label.toLowerCase().includes('environment') || 45 | device.label.toLowerCase().includes('outward') 46 | ); 47 | 48 | if (this.backCameras.length > 0) { 49 | this.cameraId = this.backCameras[0].deviceId; // Default to first back camera 50 | } else if (videoDevices.length > 0) { 51 | this.cameraId = videoDevices[0].deviceId; // fallback 52 | } 53 | 54 | console.log('Back cameras found:', this.backCameras); 55 | } 56 | 57 | onCameraChange(event: any) { 58 | this.barcodeScanner.stop(); // Stop the scanner before changing camera 59 | 60 | this.barcodeScanner.playDevice(event.target.value); // Start the scanner with the new camera 61 | this.cameraId = event.target.value; 62 | } 63 | 64 | startScanning() { 65 | this.barcodeScanner.start().subscribe((result) => { 66 | this.barcodeScanner.devices.subscribe((devices) => { 67 | const backCamera = devices.filter( 68 | (device) => 69 | device.label.toLowerCase().includes('back') || 70 | device.label.toLowerCase().includes('rear') 71 | ); 72 | 73 | if (backCamera && backCamera.length > 1) { 74 | this.barcodeScanner.playDevice(backCamera[1].deviceId); 75 | } 76 | else 77 | this.barcodeScanner.playDevice(backCamera[0].deviceId); 78 | }); 79 | }); 80 | } 81 | 82 | stopScanning() { 83 | this.barcodeScanner.stop(); 84 | } 85 | 86 | onScanSuccess(scannedResult: any) { 87 | this.barcodeValue = scannedResult[0].value; 88 | 89 | this.barcodeScanner.stop(); // Stop the scanner after a successful scan 90 | 91 | this.scanningComplete.emit(this.barcodeValue); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/FilamentTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using RichardSzalay.MockHttp; 3 | using System.Text.Json; 4 | 5 | namespace Gateways.Tests 6 | { 7 | internal class FilamentTests : EndpointTest 8 | { 9 | private Vendor vendor = new Vendor() { Id = 1, Name = "Vendor1" }; 10 | private Filament filament = new Filament() { Id = 2, Name = "Vendor1", Material = "PLA", ColorHex = "FFFF00" }; 11 | 12 | [Test] 13 | public async Task GivenNonExistingFilament_WhenGetOrCreate_CreatedFilamentShouldBeReturned() 14 | { 15 | // Arrange & Act 16 | var result = await Endpoint.GetOrCreate(vendor, "FFFF00", "PLA"); 17 | 18 | // Assert 19 | result.Should().NotBeNull(); 20 | result.Material.Should().Be("PLA"); 21 | result.ColorHex.Should().Be("FFFF00"); 22 | } 23 | 24 | [Test] 25 | public async Task GivenExistingFilament_WhenGetOrCreate_ExistingFilamentShouldBeReturned() 26 | { 27 | // Arrange & Act 28 | var result = await Endpoint.GetOrCreate(vendor, "FFFFFF", "PLA"); 29 | 30 | // Assert 31 | result.Should().NotBeNull(); 32 | result.Vendor.Name.Should().Be("Vendor1"); 33 | result.Material.Should().Be("PLA"); 34 | result.ColorHex.Should().Be("FFFFFF"); 35 | } 36 | 37 | public override void SetupHttpClient(MockHttpMessageHandler mockHandler) 38 | { 39 | mockHandler 40 | .When("/api/v1/filament") 41 | .WithQueryString(FilamentQueryConstants.VendorName, "Vendor1") 42 | .WithQueryString(FilamentQueryConstants.ColorHex, "FFFFFF") 43 | .WithQueryString(FilamentQueryConstants.Material, "PLA") 44 | .Respond("application/json", "[{\"id\":2,\"registered\":\"2025-03-15T15:18:26Z\",\"name\":\"Gray\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"898989\",\"extra\":{}},{\"id\":3,\"registered\":\"2025-03-15T15:18:49Z\",\"name\":\"Peru\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"FFFFFF\",\"extra\":{}}]"); 45 | 46 | mockHandler 47 | .When("/api/v1/filament") 48 | .WithQueryString(FilamentQueryConstants.VendorName, "Vendor1") 49 | .WithQueryString(FilamentQueryConstants.ColorHex, "FFFF00") 50 | .WithQueryString(FilamentQueryConstants.Material, "PLA") 51 | .Respond("application/json", "[]"); 52 | 53 | mockHandler 54 | .When("/api/v1/filament") 55 | .WithContent("{\"id\":0,\"name\":\"Yellow\",\"vendor_id\":1,\"material\":\"PLA\",\"price\":0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000,\"spool_weight\":0,\"extruder_temp\":0,\"bed_temp\":0,\"color_hex\":\"FFFF00\"}") 56 | .Respond("application/json", JsonSerializer.Serialize(filament)); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /UI/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "spoolman-updater": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:application", 19 | "options": { 20 | "outputPath": "dist/spoolman-updater", 21 | "index": "src/index.html", 22 | "browser": "src/main.ts", 23 | "polyfills": ["zone.js"], 24 | "tsConfig": "tsconfig.app.json", 25 | "inlineStyleLanguage": "scss", 26 | "assets": ["src/favicon.ico", "src/assets"], 27 | "styles": [ 28 | "@angular/material/prebuilt-themes/purple-green.css", 29 | "src/styles.scss" 30 | ], 31 | "scripts": [] 32 | }, 33 | "configurations": { 34 | "production": { 35 | "budgets": [ 36 | { 37 | "type": "initial", 38 | "maximumWarning": "2mb", 39 | "maximumError": "4mb" 40 | }, 41 | { 42 | "type": "anyComponentStyle", 43 | "maximumWarning": "2kb", 44 | "maximumError": "4kb" 45 | } 46 | ], 47 | "outputHashing": "all", 48 | "fileReplacements": [ 49 | { 50 | "replace": "src/environments/environment.ts", 51 | "with": "src/environments/environment.prod.ts" 52 | } 53 | ] 54 | }, 55 | "development": { 56 | "optimization": false, 57 | "extractLicenses": false, 58 | "sourceMap": true 59 | } 60 | }, 61 | "defaultConfiguration": "production" 62 | }, 63 | "serve": { 64 | "builder": "@angular-devkit/build-angular:dev-server", 65 | "configurations": { 66 | "production": { 67 | "buildTarget": "spoolman-updater:build:production" 68 | }, 69 | "development": { 70 | "buildTarget": "spoolman-updater:build:development" 71 | } 72 | }, 73 | "defaultConfiguration": "development" 74 | }, 75 | "extract-i18n": { 76 | "builder": "@angular-devkit/build-angular:extract-i18n", 77 | "options": { 78 | "buildTarget": "spoolman-updater:build" 79 | } 80 | }, 81 | "test": { 82 | "builder": "@angular-devkit/build-angular:karma", 83 | "options": { 84 | "polyfills": ["zone.js", "zone.js/testing"], 85 | "tsConfig": "tsconfig.spec.json", 86 | "inlineStyleLanguage": "scss", 87 | "assets": ["src/favicon.ico", "src/assets"], 88 | "styles": [ 89 | "@angular/material/prebuilt-themes/purple-green.css", 90 | "src/styles.scss" 91 | ], 92 | "scripts": [] 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Gateways/HomeAssistant/Client.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using System.Text.Json.Serialization; 3 | using System.Text.Json; 4 | using System.Text.RegularExpressions; 5 | 6 | namespace Gateways; 7 | 8 | public class HomeAssistantClient 9 | { 10 | private readonly HttpClient _httpClient; 11 | private readonly string _baseUrl; 12 | private readonly string _token; 13 | private readonly HomeAssistantConfiguration configuration; 14 | 15 | public HomeAssistantClient(HomeAssistantConfiguration configuration) 16 | { 17 | _baseUrl = configuration.Url; 18 | _token = configuration.Token; 19 | 20 | _httpClient = new HttpClient(); 21 | _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_token}"); 22 | this.configuration = configuration; 23 | } 24 | 25 | public async Task> GetAmsInfoAsync() 26 | { 27 | var amsEntities = new List(); 28 | 29 | if (configuration.AMSEntities != null && configuration.AMSEntities.Any()) 30 | { 31 | foreach (var amsEntity in configuration.AMSEntities) 32 | { 33 | var trays = await GetAllTrayInfoAsync(amsEntity); 34 | 35 | var amsEntityInfo = new AMSEntity 36 | { 37 | Id = amsEntity, 38 | Trays = trays 39 | }; 40 | 41 | amsEntities.Add(amsEntityInfo); 42 | } 43 | } 44 | else if (configuration.AMSEntities != null && configuration.TrayEntities.Any()) 45 | { 46 | var groupedByAms = configuration.TrayEntities 47 | .GroupBy(entity => 48 | { 49 | var match = Regex.Match(entity, @"ams_(\d+)"); 50 | return match.Success ? $"AMS {match.Groups[1].Value}" : "Unknown"; 51 | }); 52 | 53 | foreach (var group in groupedByAms) 54 | { 55 | var trayTasks = group.Select(entity => GetTrayInfoAsync(entity)).ToList(); 56 | var trays = await Task.WhenAll(trayTasks); 57 | 58 | var amsEntityInfo = new AMSEntity 59 | { 60 | Id = group.Key, 61 | Trays = trays.Where(tray => tray != null).ToList() 62 | }; 63 | 64 | amsEntities.Add(amsEntityInfo); 65 | } 66 | } 67 | 68 | return amsEntities; 69 | } 70 | 71 | public async Task GetExternalSpoolAsync() => await GetTrayInfoAsync(configuration.ExternalSpoolEntity); 72 | 73 | private async Task GetTrayInfoAsync(string entity) 74 | { 75 | var response = await _httpClient.GetFromJsonAsync($"{_baseUrl}/api/states/{entity}"); 76 | 77 | var trayInfo = response?.Attributes; 78 | 79 | trayInfo.Id = entity.Replace("sensor.", string.Empty); 80 | 81 | return trayInfo; 82 | } 83 | 84 | private async Task> GetAllTrayInfoAsync(string amsEntity) 85 | { 86 | var allStates = await _httpClient.GetFromJsonAsync>($"{_baseUrl}/api/states"); 87 | 88 | var regex = new Regex($@"^sensor\.{amsEntity}_.*_\d+$", RegexOptions.IgnoreCase); 89 | 90 | var sensors = allStates 91 | .Where(e => regex.IsMatch(e.EntityId)) 92 | .ToList(); 93 | 94 | var trayTasks = new List>(); 95 | 96 | sensors.ForEach(sensor => trayTasks.Add(GetTrayInfoAsync(sensor.EntityId))); 97 | 98 | var results = await Task.WhenAll(trayTasks); 99 | 100 | return new List(results); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /UI/src/app/shared/components/tray/tray.component.ts: -------------------------------------------------------------------------------- 1 | import { AsyncPipe, CommonModule } from '@angular/common'; 2 | import { Component, Input, OnInit } from '@angular/core'; 3 | import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatFormFieldModule } from '@angular/material/form-field'; 8 | import { MatInputModule } from '@angular/material/input'; 9 | import { MatSelectModule } from '@angular/material/select'; 10 | import { map, Observable, startWith } from 'rxjs'; 11 | import { Spool } from '../../models/spool'; 12 | import { Tray } from '../../models/tray'; 13 | import { SpoolsService } from '../../service/spoolman.service'; 14 | import { SpoolItemComponent } from "../spool/spool.component"; 15 | 16 | @Component({ 17 | selector: 'app-tray', 18 | standalone: true, 19 | imports: [ 20 | MatButtonModule, 21 | MatCardModule, 22 | MatSelectModule, 23 | MatFormFieldModule, 24 | CommonModule, 25 | FormsModule, 26 | MatFormFieldModule, 27 | MatInputModule, 28 | MatAutocompleteModule, 29 | ReactiveFormsModule, 30 | AsyncPipe, 31 | SpoolItemComponent 32 | ], 33 | templateUrl: './tray.component.html', 34 | styleUrls: ['./tray.component.scss'], 35 | }) 36 | export class TrayComponent implements OnInit { 37 | @Input() tray: Tray | null = null; 38 | @Input() spools: Spool[] = []; 39 | @Input() name: string = ''; 40 | 41 | currentSpool: Spool | undefined; 42 | filteredSpools: Observable = new Observable(); 43 | spoolControl = new FormControl(''); 44 | 45 | constructor(private spoolService: SpoolsService) { } 46 | 47 | ngOnInit(): void { 48 | this.currentSpool = this.getCurrentSpool(this.tray); 49 | 50 | this.filteredSpools = this.spoolControl.valueChanges.pipe( 51 | startWith(''), 52 | map(value => this.filter(value || '')), 53 | ); 54 | } 55 | 56 | filter(value: string | Spool): Spool[] { 57 | const filterValue = typeof value !== 'string' ? this.displaySpoolName(value as Spool) : value.toString().toLowerCase(); 58 | 59 | return this.spools.filter(option => this.displaySpoolName(option).toLowerCase().includes(filterValue)); 60 | } 61 | 62 | displaySpoolName(spool: Spool): string { 63 | return spool 64 | ? `${spool.filament.vendor.name} ${spool.filament.material} ${spool.filament.name} - ${spool.remaining_weight}g` 65 | : ''; 66 | } 67 | 68 | getCurrentSpool(tray: Tray | null): Spool { 69 | if (!tray) { 70 | return {} as Spool; 71 | } 72 | 73 | const currentSpool = this.spools.filter((spool) => 74 | spool.extra['active_tray']?.includes(tray.id) 75 | )[0]; 76 | 77 | return currentSpool; 78 | } 79 | 80 | onSpoolChange(selectChange: MatAutocompleteSelectedEvent, tray: Tray): void { 81 | const selectedSpoolId = selectChange.option.value.id; 82 | 83 | console.log(selectedSpoolId, tray); 84 | this.setSpoolToTray(selectedSpoolId, tray); 85 | } 86 | 87 | setSpoolToTray(selectedSpoolId: string, tray: Tray): void { 88 | tray.selectedSpool = selectedSpoolId; 89 | } 90 | 91 | updateTray(tray: Tray | undefined): void { 92 | if (!tray) return; 93 | 94 | this.spoolService 95 | .updateTray({ 96 | spool_id: tray.selectedSpool, 97 | active_tray_id: tray.id, 98 | }) 99 | .subscribe(spool => { 100 | console.log('Tray updated successfully!'); 101 | 102 | this.currentSpool = spool; 103 | this.spoolControl.setValue(null); 104 | }); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Gateways/Spoolman/Endpoints/Spool.cs: -------------------------------------------------------------------------------- 1 | using LinqKit; 2 | using System.Net.Http.Json; 3 | using System.Text.Json; 4 | 5 | namespace Gateways; 6 | 7 | internal class SpoolSpoolmanEndpoint : SpoolmanEndpoint, ISpoolEndpoint 8 | { 9 | private readonly IVendorEndpoint vendorEndpoint; 10 | private readonly IFilamentEndpoint filamentEndpoint; 11 | 12 | public SpoolSpoolmanEndpoint( 13 | SpoolmanConfiguration configuration, 14 | IVendorEndpoint vendorEndpoint, 15 | IFilamentEndpoint filamentEndpoint) : base(configuration) 16 | { 17 | this.vendorEndpoint = vendorEndpoint; 18 | this.filamentEndpoint = filamentEndpoint; 19 | } 20 | 21 | protected override string Endpoint => "spool"; 22 | 23 | public async Task> GetAllAsync() => await GetAllAsync(string.Empty, false); 24 | 25 | public async Task> GetSpoolsByBarcode(string barcode) 26 | { 27 | var allSpools = await GetAllAsync(string.Empty, false); 28 | 29 | return allSpools?.Where(GetExtraFieldPredicate("barcode", barcode)).ToList() ?? new List(); 30 | } 31 | 32 | private Func GetExtraFieldPredicate(string key, string value) 33 | { 34 | var jsonEncoded = JsonSerializer.Serialize(value, JsonOptions); 35 | 36 | return spool => spool.Extra.ContainsKey(key) && spool.Extra[key] == jsonEncoded; 37 | } 38 | 39 | public async Task> GetCurrentSpoolsInTray(string trayId) 40 | { 41 | var jsonEncoded = JsonSerializer.Serialize(trayId, JsonOptions); 42 | 43 | var allSpools = await GetAllAsync(string.Empty, false); 44 | 45 | return allSpools?.Where(spool => spool.Extra.ContainsKey(FilamentQueryConstants.ActiveTrayId) && spool.Extra[FilamentQueryConstants.ActiveTrayId] == jsonEncoded).ToList() ?? new List(); 46 | } 47 | 48 | public async Task GetOrCreateSpool(string vendorName, string material, string color, string activeTrayId, string tagUid) 49 | { 50 | var predicate = PredicateBuilder.New(true); 51 | 52 | if (!string.IsNullOrEmpty(activeTrayId)) 53 | { 54 | var jsonEncoded = JsonSerializer.Serialize(activeTrayId, JsonOptions); 55 | 56 | predicate = predicate.And(spool => spool.Extra.ContainsKey(FilamentQueryConstants.ActiveTrayId) && spool.Extra[FilamentQueryConstants.ActiveTrayId] == jsonEncoded); 57 | } 58 | else if (!string.IsNullOrEmpty(tagUid)) 59 | { 60 | var jsonEncoded = JsonSerializer.Serialize(tagUid, JsonOptions); 61 | predicate = predicate.And(spool => spool.Extra.ContainsKey(FilamentQueryConstants.TagUid) && spool.Extra[FilamentQueryConstants.TagUid] == jsonEncoded); 62 | } 63 | else 64 | { 65 | if (!string.IsNullOrEmpty(material)) 66 | { 67 | predicate = predicate.And(spool => spool.Filament.Material == material); 68 | } 69 | 70 | if (!string.IsNullOrEmpty(color)) 71 | { 72 | predicate = predicate.And(spool => color.StartsWith($"#{spool.Filament.ColorHex}", StringComparison.OrdinalIgnoreCase) == true); 73 | } 74 | } 75 | 76 | var allBrandSpools = await GetAllAsync(string.Empty, false); 77 | 78 | Spool? matchingSpool = allBrandSpools?.FirstOrDefault(predicate); 79 | 80 | matchingSpool ??= await CreateSpoolAsync(vendorName, color.Substring(1, 6), material, activeTrayId, tagUid); 81 | 82 | return matchingSpool; 83 | } 84 | 85 | public async Task SetActiveTray(int spoolId, string activeTrayId) 86 | { 87 | var spool = await GetByIdAsync(spoolId.ToString()); 88 | if (spool == null) 89 | throw new InvalidOperationException($"Spool with ID {spoolId} not found."); 90 | 91 | spool.Extra[FilamentQueryConstants.ActiveTrayId] = JsonSerializer.Serialize(activeTrayId, JsonOptions); 92 | 93 | return await UpdateAsync(spool.Id.Value, new 94 | { 95 | extra = new 96 | { 97 | active_tray = JsonSerializer.Serialize(activeTrayId, JsonOptions) 98 | } 99 | }); 100 | } 101 | 102 | public async Task UseSpoolWeight(int spoolId, float usedWeight) 103 | { 104 | var payload = new { use_weight = usedWeight }; 105 | var response = await HttpClient.PutAsJsonAsync($"{Endpoint}/{spoolId}/use", payload); 106 | 107 | return response.IsSuccessStatusCode; 108 | } 109 | 110 | private async Task CreateSpoolAsync(string vendorName, string color, string material, string activeTrayId, string tagUid) 111 | { 112 | var vendor = await vendorEndpoint.GetOrCreate(vendorName); 113 | 114 | var filament = await filamentEndpoint.GetOrCreate(vendor, color, material); 115 | 116 | var extra = new Dictionary(); 117 | 118 | if (!Spool.IsEmptyTag(tagUid)) 119 | { 120 | extra[FilamentQueryConstants.TagUid] = JsonSerializer.Serialize(tagUid, JsonOptions); 121 | } 122 | 123 | if (!string.IsNullOrEmpty(activeTrayId)) 124 | { 125 | extra[FilamentQueryConstants.ActiveTrayId] = JsonSerializer.Serialize(activeTrayId, JsonOptions); 126 | } 127 | 128 | var newSpool = new Spool 129 | { 130 | FilamentId = filament.Id, 131 | InitialWeight = 1000, // Default values, adjust as needed 132 | RemainingWeight = 1000, 133 | SpoolWeight = 250, 134 | Extra = extra 135 | }; 136 | 137 | return await PostAsync(newSpool); 138 | } 139 | 140 | public async Task GetByIdAsync(int spoolId) => await GetByIdAsync(spoolId.ToString()); 141 | } 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd 364 | /Application/appsettings.Development.json 365 | 366 | */coveragereport 367 | */TestResults 368 | -------------------------------------------------------------------------------- /Gateways.Tests/Spoolman/Endpoints/SpoolTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Moq; 3 | using RichardSzalay.MockHttp; 4 | using System.Text.Json; 5 | 6 | namespace Gateways.Tests 7 | { 8 | internal class SpoolTests : EndpointTest 9 | { 10 | private Vendor vendor = new Vendor() { Id = 1, Name = "Vendor1" }; 11 | private Filament filament = new Filament() { Id = 2, Name = "Vendor1", Material = "PLA", ColorHex = "FFFF00", Vendor = new Vendor() { Id = 1, Name = "Vendor1" } }; 12 | 13 | [Test] 14 | public async Task GivenIdAndWeight_WhenUseSpoolWeight_ShouldReturnTrue() 15 | { 16 | // Arrange & Act 17 | var result = await Endpoint.UseSpoolWeight(2, 100); 18 | 19 | // Assert 20 | result.Should().BeTrue(); 21 | } 22 | 23 | [Test] 24 | public async Task GivenNonExistingSpool_WhenGetOrCreate_CreatedSpoolShouldBeReturned() 25 | { 26 | // Arrange & Act 27 | var result = await Endpoint.GetOrCreateSpool("Vendor1", "PLA", "#FFFF0000", string.Empty, string.Empty); 28 | 29 | // Assert 30 | result.Should().NotBeNull(); 31 | result.Filament.Vendor.Name.Should().Be("Vendor1"); 32 | result.Filament.Material.Should().Be("PLA"); 33 | result.Filament.ColorHex.Should().Be("FFFF00"); 34 | } 35 | 36 | [Test] 37 | public async Task GivenExistingSpoolWithTrayId_WhenGetOrCreate_ExistingSpoolShouldBeReturned() 38 | { 39 | // Arrange & Act 40 | var result = await Endpoint.GetOrCreateSpool("Vendor1", "PLA", "#B7B3A800", "tray_1", string.Empty); 41 | 42 | // Assert 43 | result.Should().NotBeNull(); 44 | result.Filament.Vendor.Name.Should().Be("Vendor1"); 45 | result.Filament.Material.Should().Be("PLA"); 46 | result.Filament.ColorHex.Should().Be("B7B3A8"); 47 | result.Extra["active_tray"].Should().Be("\"tray_1\""); 48 | } 49 | 50 | [Test] 51 | public async Task GivenExistingSpoolWithTag_WhenGetOrCreate_ExistingSpoolShouldBeReturned() 52 | { 53 | // Arrange & Act 54 | var result = await Endpoint.GetOrCreateSpool("Vendor1", "PLA", "#89898900", string.Empty, "11"); 55 | 56 | // Assert 57 | result.Should().NotBeNull(); 58 | result.Filament.Vendor.Name.Should().Be("Vendor1"); 59 | result.Filament.Material.Should().Be("PLA"); 60 | result.Filament.ColorHex.Should().Be("898989"); 61 | result.Extra["tag"].Should().Be("\"11\""); 62 | } 63 | 64 | [Test] 65 | public async Task GivenExistingSpool_WhenGetOrCreate_ExistingSpoolShouldBeReturned() 66 | { 67 | // Arrange & Act 68 | var result = await Endpoint.GetOrCreateSpool("Vendor1", "PLA", "#FFFFFFFF", string.Empty, string.Empty); 69 | 70 | // Assert 71 | result.Should().NotBeNull(); 72 | result.Filament.Vendor.Name.Should().Be("Vendor1"); 73 | result.Filament.Material.Should().Be("PLA"); 74 | result.Filament.ColorHex.Should().Be("FFFFFF"); 75 | } 76 | 77 | public override void SetupHttpClient(MockHttpMessageHandler mockHandler) 78 | { 79 | mockHandler 80 | .When(HttpMethod.Get, "/api/v1/spool") 81 | .Respond("application/json", "[{\"id\":1,\"registered\":\"2025-03-18T18:40:29Z\",\"first_used\":\"2025-03-20T15:27:42Z\",\"last_used\":\"2025-04-08T15:24:07Z\",\"filament\":{\"id\":1,\"registered\":\"2025-03-18T18:40:29Z\",\"name\":\"Gray\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"898989\",\"extra\":{}},\"remaining_weight\":950.79,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":49.21,\"remaining_length\":318784.3125052654,\"used_length\":16499.306911498978,\"archived\":false,\"extra\":{\"tag\":\"\\\"11\\\"\"}},{\"id\":2,\"registered\":\"2025-03-18T18:40:31Z\",\"first_used\":\"2025-04-09T14:21:03Z\",\"last_used\":\"2025-04-09T14:21:03Z\",\"filament\":{\"id\":2,\"registered\":\"2025-03-18T18:40:31Z\",\"name\":\"Peru\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"B87333\",\"extra\":{}},\"remaining_weight\":996.0,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":4.0,\"remaining_length\":333942.48493909737,\"used_length\":1341.1344776670576,\"archived\":false,\"extra\":{\"active_tray\":\"\\\"tray_2\\\"\"}},{\"id\":3,\"registered\":\"2025-03-18T18:40:32Z\",\"first_used\":\"2025-03-28T10:54:30Z\",\"last_used\":\"2025-03-28T10:54:30Z\",\"filament\":{\"id\":3,\"registered\":\"2025-03-18T18:40:32Z\",\"name\":\"White\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"FFFFFF\",\"extra\":{}},\"remaining_weight\":991.0,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":9.0,\"remaining_length\":332266.06684201356,\"used_length\":3017.5525747508796,\"archived\":false,\"extra\":{}},{\"id\":4,\"registered\":\"2025-03-18T18:40:33Z\",\"first_used\":\"2025-03-18T18:41:33Z\",\"last_used\":\"2025-03-18T18:41:33Z\",\"filament\":{\"id\":4,\"registered\":\"2025-03-18T18:40:33Z\",\"name\":\"Black\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"161616\",\"extra\":{}},\"remaining_weight\":990.0,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":10.0,\"remaining_length\":331930.78322259674,\"used_length\":3352.836194167644,\"archived\":false,\"extra\":{}},{\"id\":6,\"registered\":\"2025-03-21T11:29:08Z\",\"first_used\":\"2025-03-21T11:29:08Z\",\"last_used\":\"2025-04-12T16:11:26Z\",\"filament\":{\"id\":6,\"registered\":\"2025-03-21T11:29:08Z\",\"name\":\"Black Glossy\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PETG\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"161616\",\"extra\":{}},\"remaining_weight\":835.22,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":164.78,\"remaining_length\":280035.58460927,\"used_length\":55248.034807494434,\"archived\":false,\"extra\":{\"active_tray\":\"\\\"tray_4\\\"\"}},{\"id\":7,\"registered\":\"2025-04-07T17:42:15Z\",\"first_used\":\"2025-04-07T17:42:15Z\",\"last_used\":\"2025-04-12T16:19:41Z\",\"filament\":{\"id\":7,\"registered\":\"2025-04-07T17:42:15Z\",\"name\":\"Silver\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"C6C4D2\",\"extra\":{}},\"remaining_weight\":419.0,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":581.0,\"remaining_length\":140483.83653562426,\"used_length\":194799.78288114013,\"archived\":false,\"extra\":{\"active_tray\":\"\\\"tray_3\\\"\"}},{\"id\":8,\"registered\":\"2025-04-09T15:43:13Z\",\"first_used\":\"2025-04-09T15:43:13Z\",\"last_used\":\"2025-04-20T16:27:32Z\",\"filament\":{\"id\":8,\"registered\":\"2025-04-09T15:43:13Z\",\"name\":\"DarkGray\",\"vendor\":{\"id\":1,\"registered\":\"2025-03-10T20:38:35Z\",\"name\":\"Vendor1\",\"external_id\":\"Vendor1\",\"extra\":{}},\"material\":\"PLA\",\"price\":0.0,\"density\":1.24,\"diameter\":1.75,\"weight\":1000.0,\"spool_weight\":0.0,\"color_hex\":\"B7B3A8\",\"extra\":{}},\"remaining_weight\":896.0,\"initial_weight\":1000.0,\"spool_weight\":250.0,\"used_weight\":104.0,\"remaining_length\":300414.12299742096,\"used_length\":34869.4964193435,\"archived\":false,\"extra\":{\"active_tray\":\"\\\"tray_1\\\"\"}}]"); 82 | 83 | mockHandler 84 | .When(HttpMethod.Post, "/api/v1/spool") 85 | .WithContent("{\"filament_id\":2,\"remaining_weight\":1000,\"initial_weight\":1000,\"spool_weight\":250,\"used_length\":0,\"archived\":false,\"extra\":{}}") 86 | .Respond("application/json", JsonSerializer.Serialize(new Spool() 87 | { 88 | Id = 6, 89 | Filament = filament 90 | })); 91 | 92 | mockHandler 93 | .When(HttpMethod.Put, "/api/v1/spool/2/use") 94 | .WithContent("{\"use_weight\":100}") 95 | .Respond("application/json", ""); 96 | } 97 | 98 | public override void SetupConstructorArguments() 99 | { 100 | var vendorEndpoint = new Mock(); 101 | vendorEndpoint.Setup(endpoint => endpoint.GetOrCreate(It.IsAny())).Returns(Task.FromResult(vendor)); 102 | 103 | var filamentEndpoint = new Mock(); 104 | filamentEndpoint.Setup(endpoint => endpoint.GetOrCreate(It.IsAny(), It.IsAny(), It.IsAny())) 105 | .Returns(Task.FromResult(filament)); 106 | 107 | ConstructorArguments = new object[] 108 | { 109 | vendorEndpoint.Object, 110 | filamentEndpoint.Object, 111 | }; 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spoolman Updater API 2 | 3 | ## Overview 4 | 5 | The Spoolman Updater API provides endpoints to manage spool updates, including tracking filament usage and material details. This API is designed to work with Home Assistant and other automation systems. 6 | 7 | To facilitate API development and testing, the Spoolman Updater API utilizes Swagger for interactive API documentation. You can access the Swagger UI at http://:8088/swagger, which allows you to explore and test the available endpoints. 8 | 9 | > [!TIP] 10 | > The new UI add abilities to set which spool is in which tray of the AMS. Also there is a scan button (top right) that allows you to scan a barcode/qrcode on a spool and that will lead to a page where you can set in which tray the spool is. 11 | 12 | ## PLEASE READ ALL THE INSTRUCTIONS, THERE IS A DIFFERENCE BASED ON THE VERSION OF YOUR BAMBU LAB HA INTEGRATION 13 | 14 | ## Base URL 15 | 16 | ``` 17 | http://:8088 18 | ``` 19 | 20 | ## Environment Variables 21 | 22 | The API requires the following environment variables to be set: 23 | 24 | | Variable Name | Type | Example | Description | 25 | | ----------------- | ------------- | ----------------------------------- | ------------------------------------------------ | 26 | | APPLICATION__HOMEASSISTANT__URL | string | | The URL to Home Assistant, with portnumber | 27 | | APPLICATION__HOMEASSISTANT__TOKEN | string | | The Home Assistant Long-lived access token [more info](https://community.home-assistant.io/t/how-to-get-long-lived-access-token/162159/5?u=marcokreeft87) Don't forget the quotes! | 28 | | APPLICATION__HOMEASSISTANT__AMSENTITIES__0 | string | X1C_00xxxxxxxxxxxxx_AMS_1 | The Device ID of your AMS, when there are multiples AMS in your configuration just add another var and replace the _0 with_1 and so on | 29 | | APPLICATION__HOMEASSISTANT__TRAYENTITIES__0 | string | X1C_00xxxxxxxxxxxxx_AMS_1_tray_1 | The tray sensors of your AMS trays. If you want to use this, remove APPLICATION__HOMEASSISTANT__AMSENTITIES or leave empty. Same as in AMSENTITIES replace __0 with 1 and so on for more tray sensors | 30 | | APPLICATION__HOMEASSISTANT__EXTERNALSPOOLENTITY | string | sensor.x1x_externalspool_external_spool | The URL to Home Assistant, with portnumber | 31 | | APPLICATION__SPOOLMAN__URL | string | | The URL to Spoolman, with portnumber | 32 | 33 | ## Running with Docker 34 | 35 | ### **Run the container** 36 | 37 | ``` 38 | docker run -d -p 8088:8080 \ 39 | -e APPLICATION__HOMEASSISTANT__URL=http://homeassistant.local:8123 \ 40 | -e APPLICATION__HOMEASSISTANT__TOKEN=your-token \ 41 | -e APPLICATION__SPOOLMAN__URL=http://spoolman.local:7912 \ 42 | -e APPLICATION__HOMEASSISTANT__AMSENTITIES__0=x1c_ams_1 \ 43 | -e APPLICATION__HOMEASSISTANT__EXTERNALSPOOLENTITY=sensor.x1c_external_spool \ 44 | --name spoolman-updater spoolman-updater 45 | ``` 46 | 47 | or 48 | 49 | ``` 50 | docker run -d -p 8088:8080 \ 51 | -e APPLICATION__HOMEASSISTANT__URL=http://homeassistant.local:8123 \ 52 | -e APPLICATION__HOMEASSISTANT__TOKEN=your-token \ 53 | -e APPLICATION__SPOOLMAN__URL=http://spoolman.local:7912 \ 54 | -e APPLICATION__HOMEASSISTANT__TRAYENTITIES__0=sensor.x1c_ams_1_tray_1 \ 55 | -e APPLICATION__HOMEASSISTANT__TRAYENTITIES__1=sensor.x1c_ams_1_tray_2 \ 56 | -e APPLICATION__HOMEASSISTANT__TRAYENTITIES__2=sensor.x1c_ams_1_tray_3 \ 57 | -e APPLICATION__HOMEASSISTANT__TRAYENTITIES__3=sensor.x1c_ams_1_tray_4 \ 58 | -e APPLICATION__HOMEASSISTANT__EXTERNALSPOOLENTITY=sensor.x1c_external_spool \ 59 | --name spoolman-updater spoolman-updater 60 | ``` 61 | 62 | ## Using with Home Assistant 63 | 64 | The Spoolman Updater API can be integrated into Home Assistant automations to track filament usage automatically. 65 | 66 | # ***Below is for Bambu Lab integration 2.1.8 and below.** 67 | 68 | ### **1. Define a REST Command in `configuration.yaml`** 69 | 70 | Add the following to your `configuration.yaml` to create a REST command that updates the spool: 71 | 72 | ```yaml 73 | rest_command: 74 | update_spool: 75 | url: "http://:8088/Spools" 76 | method: POST 77 | headers: 78 | Content-Type: "application/json" 79 | payload: > 80 | { 81 | "name": "{{ filament_name }}", 82 | "material": "{{ filament_material }}", 83 | "tag_uid": "{{ filament_tag_uid }}", 84 | "used_weight": {{ filament_used_weight | int }}, 85 | "color": "{{ filament_color }}", 86 | "active_tray_id": "{{ filament_active_tray_id }}" 87 | } 88 | ``` 89 | ### **2. Create the sensors** 90 | 91 | ```yaml 92 | utility_meter: 93 | bambulab_filament_usage_meter: 94 | unique_id: 148d1e2d-87b2-4883-a923-a36a2c9fa0ac 95 | source: sensor.bambulab_filament_usage 96 | cycle: weekly 97 | 98 | ``` 99 | and 100 | 101 | ```yaml 102 | sensor: 103 | - platform: template 104 | sensors: 105 | bambulab_filament_usage: 106 | unique_id: b954300e-d3a2-44ab-948f-39c30b2f0c00 107 | friendly_name: "Bambu Lab Filament Usage" 108 | value_template: "{{ states('sensor.bambu_lab_p1s_gewicht_van_print') | float(0) / 100 * states('sensor.bambu_lab_p1s_printvoortgang') | float(0) }}" 109 | availability_template: "{% if is_state('sensor.bambu_lab_p1s_gewicht_van_print', 'unknown') or is_state('sensor.bambu_lab_p1s_gewicht_van_print', 'unavailable') %} false {% else %} true {%- endif %}" 110 | ``` 111 | 112 | Don't forget to change the sensor ids to your own :) 113 | 114 | ### **3. Create an Automation** 115 | 116 | The following automation updates the spool when a print finishes or when the AMS tray switches: 117 | 118 | ```yaml 119 | alias: Bambulab - Update Spool When Print Finishes or Tray Switches 120 | description: "" 121 | triggers: 122 | - trigger: state 123 | entity_id: 124 | - sensor.x1c_active_tray_index 125 | conditions: 126 | - condition: template 127 | value_template: "{{ trigger.from_state.state not in ['unknown', 'unavailable'] }}" 128 | - condition: template 129 | value_template: "{{ trigger.from_state.state | int > 0 }}" 130 | actions: 131 | - variables: 132 | tray_number: >- 133 | {{ trigger.from_state.state if trigger.entity_id == 134 | 'sensor.x1c_active_tray_index' else 135 | states('sensor.x1c_active_tray_index') }} 136 | tray_sensor: sensor.x1c_00m09c422100420_ams_1_tray_{{ tray_number }} 137 | tray_weight: >- 138 | {{ states('sensor.bambulab_filament_usage_meter') | float(0) | round(2) 139 | }} 140 | tag_uid: "{{ state_attr(tray_sensor, 'tag_uid') }}" 141 | material: "{{ state_attr(tray_sensor, 'type') }}" 142 | name: "{{ state_attr(tray_sensor, 'name') }}" 143 | color: "{{ state_attr(tray_sensor, 'color') }}" 144 | - data: 145 | filament_name: "{{ name }}" 146 | filament_material: "{{ material }}" 147 | filament_tag_uid: "{{ tag_uid }}" 148 | filament_used_weight: "{{ tray_weight }}" 149 | filament_color: "{{ color }}" 150 | filament_active_tray_id: "{{ tray_sensor | replace('sensor.', '') }}" 151 | action: rest_command.update_spool 152 | - action: utility_meter.calibrate 153 | data: 154 | value: "0" 155 | target: 156 | entity_id: sensor.bambulab_filament_usage_meter 157 | 158 | ``` 159 | 160 | This automation ensures that the filament usage is automatically updated in Spoolman when a print is completed or the AMS tray is changed. 161 | 162 | --- 163 | 164 | # ***FOR Bambu Lab integration 2.1.9 and above for P1S/X1C, This will update Spoolman with every tray change during Multi Color Printing** 165 | 166 | This counts for the P1S and most likely for the X1C also: 167 | 168 | ### **1. Define a REST Command and Utility Meter in `configuration.yaml`** 169 | 170 | Add the following to your `configuration.yaml` to create a REST command that updates the spool: 171 | 172 | ```yaml 173 | utility_meter: 174 | bambulab_filament_usage_meter: 175 | unique_id: 148d1e2d-87b2-4883-a923-a36a2c9fa0ac 176 | source: sensor.bambulab_filament_usage 177 | cycle: weekly 178 | 179 | rest_command: 180 | update_spool: 181 | url: "http://192.168.1.192:8088/Spools" 182 | method: POST 183 | headers: 184 | Content-Type: "application/json" 185 | payload: > 186 | { 187 | "name": "{{ filament_name }}", 188 | "material": "{{ filament_material }}", 189 | "tag_uid": "{{ filament_tag_uid }}", 190 | "used_weight": {{ filament_used_weight | int }}, 191 | "color": "{{ filament_color }}", 192 | "active_tray_id": "{{ filament_active_tray_id }}" 193 | } 194 | 195 | 196 | ``` 197 | 198 | ### **2. Create the sensors. Below example is when you have a dedicated sensors.yaml. If you don't have that make sure you use the correct indentations** 199 | 200 | ```yaml 201 | #BambuLab 202 | - platform: template 203 | sensors: 204 | bambulab_filament_usage: 205 | unique_id: b954300e-d3a2-44ab-948f-39c30b2f0c00 206 | friendly_name: "Bambu Lab Filament Usage" 207 | value_template: > 208 | {{ states('sensor.ID_PRINTER_print_weight') | float(0) / 100 * 209 | states('sensor.ID_PRINTER_print_progress') | float(0) }} 210 | availability_template: >- 211 | {% if is_state('sensor.ID_PRINTER_print_weight', 'unknown') 212 | or is_state('sensor.ID_PRINTER_print_weight', 'unavailable') %} 213 | false 214 | {% else %} 215 | true 216 | {% endif %} 217 | 218 | bambulab_ams1_tray_index: 219 | friendly_name: "Bambu Lab AMS1 Tray Index" 220 | value_template: >- 221 | {% set trays = [1,2,3,4] %} 222 | {% for tray in trays %} 223 | {% set eid = 'sensor.ID_PRINTER_ams_1_tray_' ~ tray %} 224 | {% if state_attr(eid, 'active') in [true, 'true', 'True'] %} 225 | {{ tray }} 226 | {% break %} 227 | {% endif %} 228 | {% endfor %} 229 | availability_template: >- 230 | {{ expand([ 231 | 'sensor.ID_PRINTER_ams_1_tray_1', 232 | 'sensor.ID_PRINTER_ams_1_tray_2', 233 | 'sensor.ID_PRINTER_ams_1_tray_3', 234 | 'sensor.ID_PRINTER_ams_1_tray_4' 235 | ]) | selectattr('attributes.active','defined') | list | count > 0 }} 236 | ``` 237 | 238 | ### **3. Create an Automation. Below is the automation including log write for easy troubleshooting. Place this automation in your automations.yaml** 239 | 240 | ```yaml 241 | - id: '1755865274550' 242 | alias: Bambulab - Update Spool 243 | description: Automation with tray-logging 244 | triggers: 245 | - entity_id: sensor.bambulab_ams1_tray_index 246 | trigger: state 247 | - entity_id: sensor.ID_PRINTER_current_stage 248 | to: 249 | - finished 250 | - idle 251 | trigger: state 252 | conditions: 253 | - condition: template 254 | value_template: '{{ states(''sensor.bambulab_ams1_tray_index'')|int(-1) >= 0 }}' 255 | - condition: or 256 | conditions: 257 | - condition: template 258 | value_template: '{{ trigger.entity_id != ''sensor.bambulab_ams1_tray_index'' 259 | }}' 260 | - condition: template 261 | value_template: '{{ states(''sensor.ID_PRINTER_current_stage'') == 262 | ''printing'' }}' 263 | actions: 264 | - variables: 265 | tray_number: "{% if trigger.entity_id == 'sensor.bambulab_ams1_tray_index' %}\n 266 | \ {{ trigger.from_state.state | int }}\n{% else %}\n {{ states('sensor.bambulab_ams1_tray_index') 267 | | int }}\n{% endif %}" 268 | tray_sensor: sensor.ID_PRINTER_ams_1_tray_{{ tray_number }} 269 | tray_weight: '{{ states(''sensor.bambulab_filament_usage_meter'') | float(0) 270 | | round(2) }}' 271 | tag_uid: '{{ state_attr(tray_sensor, ''tag_uid'') }}' 272 | material: '{{ state_attr(tray_sensor, ''type'') }}' 273 | name: '{{ state_attr(tray_sensor, ''name'') }}' 274 | color: '{{ state_attr(tray_sensor, ''color'') }}' 275 | - choose: 276 | - conditions: 277 | - condition: template 278 | value_template: '{{ trigger.entity_id == ''sensor.bambulab_ams1_tray_index'' 279 | }}' 280 | sequence: 281 | - action: system_log.write 282 | data: 283 | message: 'Tray-change: from tray {{ trigger.from_state.state }} to {{ trigger.to_state.state 284 | }}. Weight booked on tray {{ tray_number }}. Filament: {{ name }} ({{ 285 | material }}) Weight: {{ tray_weight }} g Color: {{ color }} Tag UID: 286 | {{ tag_uid }}' 287 | level: info 288 | default: 289 | - action: system_log.write 290 | data: 291 | message: 'Print-end: tray {{ tray_number }} Filament: {{ name }} ({{ material 292 | }}) Weight: {{ tray_weight }} g Color: {{ color }} Tag UID: {{ tag_uid 293 | }}' 294 | level: info 295 | - data: 296 | filament_name: '{{ name }}' 297 | filament_material: '{{ material }}' 298 | filament_tag_uid: '{{ tag_uid }}' 299 | filament_used_weight: '{{ tray_weight }}' 300 | filament_color: '{{ color }}' 301 | filament_active_tray_id: '{{ tray_sensor | replace(''sensor.'', '''') }}' 302 | action: rest_command.update_spool 303 | - data: 304 | value: '0' 305 | target: 306 | entity_id: sensor.bambulab_filament_usage_meter 307 | action: utility_meter.calibrate 308 | mode: single 309 | 310 | ``` 311 | 312 | # ***FOR Bambu Lab integration 2.1.9 and above H2S/H2D. This will update Spoolman with every tray change during Multi Color Printing** 313 | 314 | This counts for the H2S and most likely for the H2D also. 315 | The H2S and possible the H2D also, need and adjustment. The active AMS tray for the P1S remains active after print. Meaning the automation works without issues. 316 | With the H2S the active tray becomes directly inactive after the print, which prevents the automation to fire. For this to work you need to create a helper and automation 317 | Utility meter, rest command and sensors remain the same as for the P1S. Follow step 1 and 2 for the P1S instructions. 318 | 319 | ### **3. Create the helper in the configurations.yaml** 320 | 321 | This is now set to max 5 trays. In case you are working with multiple AMS systems, bump this number up. 322 | 323 | ```yaml 324 | input_number: 325 | bambulab_last_tray: 326 | name: "BambuLab Last Tray" 327 | min: 0 328 | max: 5 329 | step: 1 330 | ``` 331 | 332 | ### **4. Create an Automation. Below is the automation including log write for easy troubleshooting. Place this automation in your automations.yaml** 333 | 334 | ```yaml 335 | - id: '1756972720618' 336 | alias: Bambulab - Update Spool (with last_tray helper + debug ) 337 | description: Update spool 338 | triggers: 339 | - entity_id: sensor.bambulab_ams1_tray_index 340 | id: tray 341 | trigger: state 342 | - entity_id: sensor.ID_PRINTER_current_stage 343 | to: 344 | - finished 345 | - idle 346 | id: print_end 347 | trigger: state 348 | actions: 349 | - choose: 350 | - conditions: 351 | - condition: trigger 352 | id: tray 353 | sequence: 354 | - target: 355 | entity_id: input_number.bambulab_last_tray 356 | data: 357 | value: '{{ trigger.to_state.state | int }}' 358 | action: input_number.set_value 359 | - data: 360 | message: 'Tray change: input_number.bambulab_last_tray -> {{ trigger.to_state.state 361 | }}' 362 | level: info 363 | action: system_log.write 364 | - conditions: 365 | - condition: trigger 366 | id: print_end 367 | sequence: 368 | - variables: 369 | tray_number: '{{ states(''input_number.bambulab_last_tray'') | int }}' 370 | tray_sensor: sensor.ID_PRINTER_ams_1_tray_{{ tray_number }} 371 | tray_weight: '{{ states(''sensor.bambulab_filament_usage_meter'') | float(0) 372 | | round(2) }}' 373 | tag_uid: '{{ state_attr(tray_sensor, ''tag_uid'') }}' 374 | material: '{{ state_attr(tray_sensor, ''type'') }}' 375 | name: '{{ state_attr(tray_sensor, ''name'') }}' 376 | color: '{{ state_attr(tray_sensor, ''color'') }}' 377 | - data: 378 | message: 'Print end trigger. Last Tray: {{ tray_number }} ({{ name 379 | }} - {{ material }}) Weight: {{ tray_weight }}g Color: {{ color }} Tag 380 | UID: {{ tag_uid }}' 381 | level: info 382 | action: system_log.write 383 | - data: 384 | filament_name: '{{ name }}' 385 | filament_material: '{{ material }}' 386 | filament_tag_uid: '{{ tag_uid }}' 387 | filament_used_weight: '{{ tray_weight }}' 388 | filament_color: '{{ color }}' 389 | filament_active_tray_id: '{{ tray_sensor | replace(''sensor.'', '''') }}' 390 | action: rest_command.update_spool 391 | - target: 392 | entity_id: sensor.bambulab_filament_usage_meter 393 | data: 394 | value: '0' 395 | action: utility_meter.calibrate 396 | mode: single 397 | 398 | ``` 399 | ### It might be that the automation for the H2S also works for the P1S but this is untested. 400 | 401 | 402 | 403 | ### Setting the active tray in the UI when switching spools 404 | 405 | When you switch your spool in the AMS, you will need to tell spoolman which tray the new spool is in. You can do this in the UI of Spoolman updater. 406 | Just go to the base of the URL of the API. So for example if your API url is you go to 407 | 408 | ![alt text](image.png) 409 | 410 | Here you can set which spool is in which tray. 411 | 412 | ## Contributing 413 | 414 | Pull requests are welcome! Please follow the standard GitHub workflow: 415 | 416 | 1. Fork the repository 417 | 2. Create a feature branch 418 | 3. Submit a pull request 419 | 420 | ## License 421 | 422 | MIT License. See `LICENSE` file for details. 423 | --------------------------------------------------------------------------------