naps2/NAPS2.Escl.Server/EsclApiController.cs
2023-12-13 21:46:45 -08:00

293 lines
12 KiB
C#

using System.Reflection;
using System.Xml.Linq;
using EmbedIO;
using EmbedIO.Routing;
using EmbedIO.WebApi;
using Microsoft.Extensions.Logging;
namespace NAPS2.Escl.Server;
internal class EsclApiController : WebApiController
{
private static readonly XNamespace ScanNs = EsclXmlHelper.ScanNs;
private static readonly XNamespace PwgNs = EsclXmlHelper.PwgNs;
private readonly EsclDeviceConfig _deviceConfig;
private readonly EsclServerState _serverState;
private readonly ILogger _logger;
internal EsclApiController(EsclDeviceConfig deviceConfig, EsclServerState serverState, ILogger logger)
{
_deviceConfig = deviceConfig;
_serverState = serverState;
_logger = logger;
}
[Route(HttpVerbs.Get, "/ScannerCapabilities")]
public async Task GetScannerCapabilities()
{
var caps = _deviceConfig.Capabilities;
var iconUri = caps.IconPng != null ? $"http://naps2-{caps.Uuid}.local.:{_deviceConfig.Port}/eSCL/icon.png" : "";
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerCapabilities",
new XElement(PwgNs + "Version", caps.Version),
new XElement(PwgNs + "MakeAndModel", caps.MakeAndModel),
new XElement(PwgNs + "SerialNumber", caps.SerialNumber),
new XElement(ScanNs + "UUID", caps.Uuid),
new XElement(ScanNs + "AdminURI", ""),
new XElement(ScanNs + "IconURI", iconUri),
new XElement(ScanNs + "Naps2Extensions", "Progress;ErrorDetails"),
new XElement(ScanNs + "Platen",
new XElement(ScanNs + "PlatenInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "Adf",
new XElement(ScanNs + "AdfSimplexInputCaps", GetCommonInputCaps()),
new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())),
new XElement(ScanNs + "CompressionFactorSupport",
new XElement(ScanNs + "Min", 0),
new XElement(ScanNs + "Max", 100),
new XElement(ScanNs + "Normal", 75),
new XElement(ScanNs + "Step", 1))));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
}
private object[] GetCommonInputCaps()
{
// TODO: After implementing scanner capabilities this should be scanner-specific
return
[
new XElement(ScanNs + "MinWidth", "1"),
new XElement(ScanNs + "MaxWidth", EsclInputCaps.DEFAULT_MAX_WIDTH),
new XElement(ScanNs + "MinHeight", "1"),
new XElement(ScanNs + "MaxHeight", EsclInputCaps.DEFAULT_MAX_HEIGHT),
new XElement(ScanNs + "MaxScanRegions", "1"),
new XElement(ScanNs + "SettingProfiles",
new XElement(ScanNs + "SettingProfile",
new XElement(ScanNs + "ColorModes",
new XElement(ScanNs + "ColorMode", "BlackAndWhite1"),
new XElement(ScanNs + "ColorMode", "Grayscale8"),
new XElement(ScanNs + "ColorMode", "RGB24")),
new XElement(ScanNs + "DocumentFormats",
new XElement(PwgNs + "DocumentFormat", "application/pdf"),
new XElement(PwgNs + "DocumentFormat", "image/jpeg"),
new XElement(PwgNs + "DocumentFormat", "image/png"),
new XElement(ScanNs + "DocumentFormatExt", "application/pdf"),
new XElement(ScanNs + "DocumentFormatExt", "image/jpeg"),
new XElement(ScanNs + "DocumentFormatExt", "image/png")
),
new XElement(ScanNs + "SupportedResolutions",
new XElement(ScanNs + "DiscreteResolutions",
CreateResolution(100),
CreateResolution(150),
CreateResolution(200),
CreateResolution(300),
CreateResolution(400),
CreateResolution(600),
CreateResolution(800),
CreateResolution(1200),
CreateResolution(2400),
CreateResolution(4800)
))))
];
}
private XElement CreateResolution(int res) =>
new(ScanNs + "DiscreteResolution",
new XElement(ScanNs + "XResolution", res.ToString()),
new XElement(ScanNs + "YResolution", res.ToString()));
[Route(HttpVerbs.Get, "/icon.png")]
public async Task GetIcon()
{
if (_deviceConfig.Capabilities.IconPng != null)
{
Response.ContentType = "image/png";
using var stream = Response.OutputStream;
var buffer = _deviceConfig.Capabilities.IconPng;
await stream.WriteAsync(buffer, 0, buffer.Length);
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScannerStatus")]
public async Task GetScannerStatus()
{
var jobsElement = new XElement(ScanNs + "Jobs");
foreach (var jobInfo in _serverState.Jobs.OrderBy(x => x.LastUpdated.ElapsedMilliseconds))
{
jobsElement.Add(new XElement(ScanNs + "JobInfo",
new XElement(PwgNs + "JobUri", $"/eSCL/ScanJobs/{jobInfo.Id}"),
new XElement(PwgNs + "JobUuid", jobInfo.Id),
new XElement(ScanNs + "Age", Math.Ceiling(jobInfo.LastUpdated.Elapsed.TotalSeconds)),
new XElement(PwgNs + "ImagesCompleted", jobInfo.ImagesCompleted),
new XElement(PwgNs + "ImagesToTransfer", jobInfo.ImagesToTransfer),
new XElement(PwgNs + "JobState", jobInfo.State.ToString()),
new XElement(PwgNs + "JobStateReasons",
new XElement(PwgNs + "JobStateReason",
jobInfo.State == EsclJobState.Processing ? "JobScanning" : "JobCompletedSuccessfully"))));
}
var scannerState = _serverState.IsProcessing ? EsclScannerState.Processing : EsclScannerState.Idle;
var adfState = _serverState.IsProcessing ? EsclAdfState.ScannerAdfProcessing : EsclAdfState.ScannedAdfLoaded;
var doc =
EsclXmlHelper.CreateDocAsString(
new XElement(ScanNs + "ScannerStatus",
new XElement(PwgNs + "Version", "2.6"),
new XElement(PwgNs + "State", scannerState),
new XElement(ScanNs + "AdfState", adfState),
jobsElement
));
Response.ContentType = "text/xml";
using var writer = new StreamWriter(HttpContext.OpenResponseStream());
await writer.WriteAsync(doc);
}
[Route(HttpVerbs.Post, "/ScanJobs")]
public void CreateScanJob()
{
// TODO: Actually use job input for scan options
EsclScanSettings settings;
try
{
var doc = XDocument.Load(Request.InputStream);
settings = SettingsParser.Parse(doc);
}
catch (Exception)
{
Response.StatusCode = 400; // Bad request
return;
}
if (_serverState.IsProcessing)
{
Response.StatusCode = 503; // Service unavailable
return;
}
_serverState.IsProcessing = true;
var jobInfo = JobInfo.CreateNewJob(_serverState, _deviceConfig.CreateJob(settings));
_serverState.AddJob(jobInfo);
Response.Headers.Add("Location", $"{Request.Url}/{jobInfo.Id}");
Response.StatusCode = 201; // Created
}
[Route(HttpVerbs.Delete, "/ScanJobs/{jobId}")]
public void CancelScanJob(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState) &&
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
{
jobState.Job.Cancel();
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ScanImageInfo")]
public void GetImageinfo(string jobId)
{
}
// This endpoint is a NAPS2-specific extension to the ESCL API.
// It gives a chunked response where each line is a double between 0 and 1 representing the current page progress.
// This endpoint should be called once before each call to NextDocument.
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/Progress")]
public async Task Progress(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState) &&
jobState.State is EsclJobState.Pending or EsclJobState.Processing)
{
SetChunkedResponse();
using var stream = Response.OutputStream;
await jobState.Job.WriteProgressTo(stream);
}
else
{
Response.StatusCode = 404;
}
}
// This endpoint is a NAPS2-specific extension to the ESCL API.
// The ESCL status-based model (where errors like "no paper in feeder" are encompassed by ScannerStatus that can be
// polled every few seconds) is a good match to a physical scanner but a poor match to NAPS2's model.
// Instead, we have a this ErrorDetails endpoint that we call when NextDocument returns a 500 error which gives us
// XML-based details for the exception that occurred.
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/ErrorDetails")]
public async Task ErrorDetails(string jobId)
{
if (_serverState.TryGetJob(jobId, out var jobState))
{
Response.ContentType = "text/xml";
using var stream = Response.OutputStream;
await jobState.Job.WriteErrorDetailsTo(stream);
}
else
{
Response.StatusCode = 404;
}
}
[Route(HttpVerbs.Get, "/ScanJobs/{jobId}/NextDocument")]
public async Task NextDocument(string jobId)
{
if (!_serverState.TryGetJob(jobId, out var jobInfo))
{
Response.StatusCode = 404;
return;
}
if (jobInfo.State == EsclJobState.Aborted)
{
Response.StatusCode = 500;
return;
}
if (jobInfo.State is not (EsclJobState.Pending or EsclJobState.Processing))
{
Response.StatusCode = 404;
return;
}
bool result;
try
{
result = await jobInfo.Job.WaitForNextDocument();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ESCL server error waiting for document");
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Aborted);
Response.StatusCode = 500;
return;
}
if (result)
{
Response.Headers.Add("Content-Location", $"/eSCL/ScanJobs/{jobInfo.Id}/1");
SetChunkedResponse();
Response.ContentType = jobInfo.Job.ContentType;
Response.ContentEncoding = null;
using var stream = Response.OutputStream;
await jobInfo.Job.WriteDocumentTo(stream);
jobInfo.TransferredDocument();
}
else
{
jobInfo.TransitionState(EsclJobState.Processing, EsclJobState.Completed);
Response.StatusCode = 404;
}
}
private void SetChunkedResponse()
{
// Bypass https://github.com/unosquare/embedio/issues/510
var field = Response.GetType().GetField("<ProtocolVersion>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic);
if (field != null)
{
field.SetValue(Response, new Version(1, 1));
}
Response.SendChunked = true;
}
}