feat: add support for http server control and entrance sensor

This commit is contained in:
Guillermo Marcel 2025-04-20 17:56:54 -03:00
parent 620096a7b1
commit 4507f5ab21
22 changed files with 663 additions and 66 deletions

View File

@ -13,6 +13,7 @@ public static class DependencyInjectionExtensions
{
services.AddSingleton<IDVRConnector, ShinobiConnector>();
services.AddSingleton<IDVRScanner, DVRScanner>();
services.AddSingleton<IShinobiLinkFactory, ShinobiLinkFactory>();
services.AddSingleton<FfmpegWrapper>();
services.AddSingleton<AutoScanApp>();

View File

@ -4,6 +4,7 @@ using AutoScan.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using Monitor = AutoScan.Models.Monitor;
namespace AutoScan.Implementations;
@ -11,19 +12,20 @@ public class ShinobiConnector : IDVRConnector
{
private readonly ILogger<ShinobiConnector> _logger;
private readonly HttpClient _httpClient;
private readonly ShinobiOptions _options;
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient, IOptions<ShinobiOptions> options)
private readonly IShinobiLinkFactory _linkFactory;
private string? _cachedVideoStream;
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient, IShinobiLinkFactory linkFactory)
{
_logger = logger;
_httpClient = httpClient;
_options = options.Value;
_linkFactory = linkFactory;
}
public async Task<List<VideoDetail>> FetchMonitorVideosBetween(DateTime from, DateTime to, bool runDry = false, CancellationToken cancellationToken = default)
{
var endpoint = $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}";
endpoint += $"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}";
var endpoint = _linkFactory.BuildVideosLink(from, to);
_logger.LogDebug("Fetching videos details from endpoint: {Endpoint}", endpoint);
@ -56,7 +58,7 @@ public class ShinobiConnector : IDVRConnector
public async Task DownloadMonitorVideo(VideoDetail video, string downloadFolder, bool runDry, CancellationToken cancellationToken = default)
{
var endpoint = $"{_options.URL}{video.href}";
var endpoint = _linkFactory.FromHref(video.href);
_logger.LogDebug("Fetching video from endpoint: {Endpoint}", endpoint);
//Video filenames format: "monitorId-2025-02-15T07-45-01.mp4"
@ -85,5 +87,15 @@ public class ShinobiConnector : IDVRConnector
}
public async Task<string> GetVideoStream()
{
if (_cachedVideoStream is not null)
return _cachedVideoStream;
var endpoint = _linkFactory.BuildMonitorLink();
_logger.LogDebug("Fetching video stream from endpoint: {Endpoint}", endpoint);
var monitors = await _httpClient.GetFromJsonAsync<Monitor[]>(endpoint);
_cachedVideoStream = monitors?.FirstOrDefault()?.MonitorDetail?.auto_host ?? null;
return _cachedVideoStream ?? "";
}
}

View File

@ -0,0 +1,36 @@
using AutoScan.Interfaces;
using AutoScan.Options;
using Microsoft.Extensions.Options;
namespace AutoScan.Implementations;
public class ShinobiLinkFactory : IShinobiLinkFactory
{
private readonly ShinobiOptions _options;
public ShinobiLinkFactory(IOptions<ShinobiOptions> options)
{
_options = options.Value;
}
public string BuildMonitorLink()
{
return $"{_options.URL}/{_options.APIKey}/monitor/{_options.GroupId}/{_options.MonitorId}";
}
public string BuildFeedLink()
{
return $"{_options.URL}/{_options.APIKey}/mjpeg/{_options.GroupId}/{_options.MonitorId}";
}
public string BuildVideosLink(DateTime from, DateTime to)
{
return $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}" +
$"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}";
}
public string FromHref(string href)
{
return $"{_options.URL}{href}";
}
}

View File

@ -6,4 +6,6 @@ public interface IDVRConnector
{
Task<List<VideoDetail>> FetchMonitorVideosBetween(DateTime from, DateTime to, bool runDry = false, CancellationToken cancellationToken = default);
Task DownloadMonitorVideo(VideoDetail video, string downloadFolder, bool runDry = false, CancellationToken cancellationToken = default);
Task<string> GetVideoStream();
}

View File

@ -0,0 +1,9 @@
namespace AutoScan.Interfaces;
public interface IShinobiLinkFactory
{
public string BuildMonitorLink();
public string BuildFeedLink();
public string BuildVideosLink(DateTime from, DateTime to);
public string FromHref(string href);
}

View File

@ -0,0 +1,24 @@
using System.Text.Json;
namespace AutoScan.Models;
public class Monitor
{
public string? ke { get; init; }
public string? name { get; init; }
public string? mid { get; init; }
public string? details { get; init; }
public string? protocol { get; init; }
public string? host { get; init; }
public int port { get; init; }
public string? path { get; init; }
public MonitorDetail? MonitorDetail => JsonSerializer.Deserialize<MonitorDetail>(details);
}
public class MonitorDetail
{
public string auto_host { get; set; }
public string muser { get; set; }
public string mpass { get; set; }
}

View File

@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CasaBotApp", "CasaBotApp\Ca
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoScan", "AutoScan\AutoScan.csproj", "{13D75ACB-7913-4C4B-B696-9BD7383012AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlServer", "ControlServer\ControlServer.csproj", "{11422EF5-FF40-4419-B72B-87F569E4DA3C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -18,5 +20,9 @@ Global
{13D75ACB-7913-4C4B-B696-9BD7383012AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13D75ACB-7913-4C4B-B696-9BD7383012AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13D75ACB-7913-4C4B-B696-9BD7383012AF}.Release|Any CPU.Build.0 = Release|Any CPU
{11422EF5-FF40-4419-B72B-87F569E4DA3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11422EF5-FF40-4419-B72B-87F569E4DA3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11422EF5-FF40-4419-B72B-87F569E4DA3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11422EF5-FF40-4419-B72B-87F569E4DA3C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -16,6 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
@ -47,6 +48,7 @@
<ItemGroup>
<ProjectReference Include="..\AutoScan\AutoScan.csproj" />
<ProjectReference Include="..\ControlServer\ControlServer.csproj" />
</ItemGroup>

View File

@ -1,14 +1,36 @@
using AutoScan;
using AutoScan.Interfaces;
using CasaBotApp.TelegramBot;
using ControlServer;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using Telegram.Bots.Types;
using BotCommand = CasaBotApp.TelegramBot.BotCommand;
namespace CasaBotApp.Extensions;
public static class CommandRegister
public class CommandRegister
{
public static void RegisterCommands(BotHandler botHandler, AutoScanApp autoScanApp)
private readonly ILogger<CommandRegister> _logger;
private readonly BotHandler _botHandler;
private readonly AutoScanApp _autoScanApp;
private readonly IControlServer _controlServer;
private readonly IShinobiLinkFactory _shinobiLinkFactory;
private readonly IDVRConnector _dvrConnector;
public CommandRegister(ILogger<CommandRegister> logger, BotHandler botHandler, AutoScanApp autoScanApp, IControlServer controlServer, IShinobiLinkFactory shinobiLinkFactory, IDVRConnector dvrConnector)
{
botHandler.RegisterCommand(new BotCommand
_logger = logger;
_botHandler = botHandler;
_autoScanApp = autoScanApp;
_controlServer = controlServer;
_shinobiLinkFactory = shinobiLinkFactory;
_dvrConnector = dvrConnector;
}
public void RegisterCommands()
{
_botHandler.RegisterCommand(new BotCommand
{
Command = "/soyandre",
Description = "Soy Andre",
@ -17,33 +39,63 @@ public static class CommandRegister
await ctx.Responder(message, "Hola vida, te amo mucho ❤️");
}
});
botHandler.RegisterCommand(new BotCommand
_botHandler.RegisterCommand(new BotCommand
{
Command = "/startScan",
Description = "Start a scan of last night images",
Action = async (message, ctx) =>
{
await ctx.Responder(message, "Starting scan 🔍📼");
await autoScanApp.StartNewScan();
await _autoScanApp.StartNewScan();
}
});
botHandler.RegisterCommand(new BotCommand
_botHandler.RegisterCommand(new BotCommand
{
Command = "/lastScan",
Command = "/lastscan",
Description = "Send the images from the last scan",
Action = async (message, ctx) =>
{
var images = autoScanApp.GetLastScanPictures();
var images = _autoScanApp.GetLastScanPictures();
if (images.Length == 0)
{
await ctx.Responder(message, "No images found");
return;
}
await botHandler.SendPhotos(message.Chat.Id, images);
await _botHandler.SendPhotos(message.Chat.Id, images);
}
});
botHandler.OnReply = async msg =>
_botHandler.RegisterCommand(new BotCommand()
{
Command = "/now",
Description = "Send the current snapshot",
Action = async (msg, ctx) =>
{
var stopwatch = Stopwatch.StartNew();
stopwatch.Start();
var outputPath = await TakeSnapshot();
stopwatch.Stop();
if (string.IsNullOrEmpty(outputPath))
{
await ctx.Responder(msg, "Error taking snapshot");
return;
}
_ = _botHandler.SendPhoto(msg.Chat.Id, outputPath, "Current snapshot");
_ = _botHandler.SendText(msg.Chat.Id, $"It took {stopwatch.ElapsedMilliseconds} ms to take the picture");
}
});
_botHandler.RegisterCommand(new BotCommand()
{
Command = "/disarm",
Description = "Disarm the Door Sensor",
Action = async (msg, ctx) =>
{
await ctx.Responder(msg, "Disarming the door sensor");
_controlServer.RequestDisarm();
}
});
_botHandler.OnReply = async msg =>
{
var originalMsg = msg.ReplyToMessage;
@ -51,36 +103,100 @@ public static class CommandRegister
if (originalMsg is not PhotoMessage photoMessage || photoMessage.Caption is null)
return;
var videoPath = autoScanApp.GetVideoPath(photoMessage.Caption);
var videoPath = _autoScanApp.GetVideoPath(photoMessage.Caption);
if (string.IsNullOrEmpty(videoPath))
{
await botHandler.SendText(msg.Chat.Id, "No video found for this image");
await _botHandler.SendText(msg.Chat.Id, "No video found for this image");
return;
}
await botHandler.SendVideo(msg.Chat.Id, videoPath);
await _botHandler.SendVideo(msg.Chat.Id, videoPath);
};
}
public static void UpdateOnScanCompleted(BotHandler botHandler, AutoScanApp autoScanApp, ILogger logger)
public void RegisterAutoScanApp()
{
autoScanApp.OnScanCompleted = async () =>
_autoScanApp.OnScanCompleted = async () =>
{
logger.LogInformation("Scan completed at {At}", DateTime.Now);
_logger.LogInformation("Scan completed at {At}", DateTime.Now);
try
{
var images = autoScanApp.GetLastScanPictures();
var images = _autoScanApp.GetLastScanPictures();
if (images.Length == 0)
{
await botHandler.UpdateText("No images found");
await _botHandler.UpdateText("No images found");
return;
}
await botHandler.UpdateText($"Scan completed, found {images.Length} images");
await botHandler.UpdatePhotos(images);
await _botHandler.UpdateText($"Scan completed, found {images.Length} images");
await _botHandler.UpdatePhotos(images);
}catch(Exception ex)
{
logger.LogError(ex, "Error while sending message");
_logger.LogError(ex, "Error while sending message");
}
};
}
public void RegisterControlServer()
{
_controlServer.OnEvent(async se =>
{
var mediaPath = await TakeSnapshot();
if (string.IsNullOrEmpty(mediaPath))
{
await _botHandler.AlertText("Unauthorized access detected 🚨🚨🚨, but no media available");
return;
}
if (se.Type == EventType.Fired)
{
await _botHandler.AlertPhoto(mediaPath,
"Unauthorized access detected 🚨 🚨 🚨",
[
new(OptionType.Url, "Camera Feed", _shinobiLinkFactory.BuildFeedLink()),
new(OptionType.Action, "Authorize", $"authorize-{se.EventId}", (data, chatId ) =>
{
_logger.LogWarning("Authorizing event {EventId}", se.EventId);
_controlServer.AuthorizeEvent(se.EventId);
return Task.FromResult("Authorization not implemented");
}),
]);
}
if (se.Type == EventType.DisarmedEntrance)
{
await _botHandler.UpdateText("Authorize access");
}
});
}
private async Task<string> TakeSnapshot()
{
var outputPath = Path.Combine(".", "media", "snp", "something.jpeg");
var originalFeed = await _dvrConnector.GetVideoStream();
var ffmArgs = $"-y -i \"{originalFeed}\" -ss 00:00:00.500 -vframes 1 {outputPath}";
var process = new Process
{
StartInfo = new ProcessStartInfo
{
//To change this, I need to make sure ffmpeg is installed
FileName = "./dvr-scanner/ffmpeg.exe",
Arguments = ffmArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.Start();
await process.WaitForExitAsync();
// You can read the output here.
// var output = await process.StandardOutput.ReadToEndAsync();
// var error = await process.StandardError.ReadToEndAsync();
if(process.ExitCode != 0)
{
_logger.LogError("Error taking snapshot, exit code: {ExitCode}", process.ExitCode);
return string.Empty;
}
return outputPath;
}
}

View File

@ -1,7 +1,8 @@
using AutoScan;
using AutoScan.Options;
using CasaBotApp;
using CasaBotApp.Extensions;
using CasaBotApp.TelegramBot;
using ControlServer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -10,16 +11,16 @@ using Telegram.Bots;
using Telegram.Bots.Extensions.Polling;
using Telegram.Bots.Http;
var hostBuilder = new HostBuilder();
hostBuilder.ConfigureServices((_, services) =>
{
var environment = Environment.GetEnvironmentVariable("CASABOT_ENVIRONMENT");
IConfigurationRoot configuration = new ConfigurationBuilder()
var configuration = new ConfigurationBuilder()
.AddJsonFile($"appsettings.json", true, true)
.AddJsonFile($"appsettings.{environment}.json", true, true)
.AddEnvironmentVariables()
.Build();
var hostBuilder = new HostBuilder();
hostBuilder.ConfigureServices((_, services) =>
{
services.AddSingleton(configuration);
services.AddLogging(configuration);
@ -27,14 +28,20 @@ hostBuilder.ConfigureServices((_, services) =>
services.Configure<TelegramOptions>(configuration.GetSection("Telegram"));
services.Configure<AutoScanOptions>(configuration.GetSection("AutoScan"));
services.Configure<ShinobiOptions>(configuration.GetSection("Shinobi"));
services.Configure<ControlServerOptions>(configuration.GetSection("ControlServer"));
services.AddSingleton<BotHandler>();
services.AddSingleton<IControlServer, ControlServer.ControlServer>();
services.AddHostedService(sp => sp.GetRequiredService<IControlServer>());
var token = configuration["Telegram:BotToken"] ?? "";
services.AddSingleton<BotHandler>();
services.AddBotClient(token);
services.AddPolling<BotHandler>();
services.AddSingleton<IUpdateHandler>(sp => sp.GetService<BotHandler>()!);
services.AddTransient<CommandRegister>();
// To get notifications when a retry is performed
@ -47,6 +54,7 @@ hostBuilder.ConfigureServices((_, services) =>
services.Configure<HostOptions>(hostOptions =>
{
hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
hostOptions.ServicesStartConcurrently = true;
});
});
@ -56,17 +64,18 @@ var host = hostBuilder.Build();
var logger = host.Services.GetService<ILogger<Program>>()!;
var botHandler = host.Services.GetService<BotHandler>()!;
var autoScanApp = host.Services.GetService<AutoScanApp>()!;
CommandRegister.RegisterCommands(botHandler, autoScanApp);
var commandRegister = host.Services.GetRequiredService<CommandRegister>();
using var cts = new CancellationTokenSource();
_ = autoScanApp.Run(cts.Token);
botHandler.Start(cts.Token);
CommandRegister.UpdateOnScanCompleted(botHandler, autoScanApp, logger);
commandRegister.RegisterAutoScanApp();
commandRegister.RegisterCommands();
commandRegister.RegisterControlServer();
logger.LogInformation("Bot started");
await host.RunAsync(cts.Token);

View File

@ -1,7 +1,6 @@
using System.Net.Mime;
using Telegram.Bots.Types;
namespace CasaBotApp;
namespace CasaBotApp.TelegramBot;
public class BotCommand
{

View File

@ -1,21 +1,28 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text;
using System.Xml.Schema;
using Telegram.Bots;
using Telegram.Bots.Extensions.Polling;
using Telegram.Bots.Requests.Usernames;
using Telegram.Bots.Requests;
using Telegram.Bots.Types;
using File = System.IO.File;
using SendMediaGroup = Telegram.Bots.Requests.Usernames.SendMediaGroup;
using SendPhotoFile = Telegram.Bots.Requests.Usernames.SendPhotoFile;
using SendText = Telegram.Bots.Requests.Usernames.SendText;
using SendVideoFile = Telegram.Bots.Requests.Usernames.SendVideoFile;
namespace CasaBotApp;
namespace CasaBotApp.TelegramBot;
public class BotHandler : IUpdateHandler
{
private readonly ILogger<BotHandler> _logger;
private readonly TelegramOptions _telegramOptions;
private readonly List<Chat> _subscribers = [];
private readonly List<Chat> _subscribersAlarm = [];
private readonly Dictionary<string, BotCommand> _commands;
//TODO hacerlo mejor.
private readonly Dictionary<string, CallbackQueueItem> _callbackFunctions = new();
private record CallbackQueueItem(DateTime inserted, Func<string, long, Task<string>> callback);
public Func<TextMessage, Task>? OnReply { get; set; } = null;
@ -34,6 +41,15 @@ public class BotHandler : IUpdateHandler
Action = RegisterUser,
Responder = Respond
});
{
RegisterCommand(new()
{
Command = "/registeralarm",
Description = "Register to receive alarms",
Action = RegisterUserAlarm,
Responder = Respond
});
}
RegisterCommand(new()
{
Command = "/photo",
@ -41,6 +57,15 @@ public class BotHandler : IUpdateHandler
Action = SendImageTest,
Responder = Respond
});
foreach (var subs in _telegramOptions.SubscribedChatIds)
{
Subscribe(subs);
}
foreach(var sub in _telegramOptions.SubscribedAlarmsChatIds)
{
SubscribeAlarm(sub);
}
}
public void RegisterCommand(BotCommand command)
@ -49,13 +74,19 @@ public class BotHandler : IUpdateHandler
_commands[command.Command] = command;
}
public void Start(CancellationToken cancellationToken)
public void SubscribeAlarm(long id)
{
foreach (var subs in _telegramOptions.SubscribedChatIds)
if (_subscribersAlarm.Any(x => x.Id == id))
{
Subscribe(subs);
_logger.LogWarning("User {Id} is already subscribed to alarm", id);
return;
}
_subscribersAlarm.Add(new Chat()
{
Id = id
});
_logger.LogInformation("User {Id} subscribed to receive alarms", id);
}
public void Subscribe(long id)
@ -77,16 +108,18 @@ public class BotHandler : IUpdateHandler
/// Send text message to all subscribers
/// </summary>
/// <param name="message"></param>
public async Task UpdateText(string message)
public Task UpdateText(string message) => UpdateTextInt(_subscribers, message);
public Task AlertText(string message) => UpdateTextInt(_subscribersAlarm, message);
public async Task UpdateTextInt(List<Chat> subscribers,string message)
{
if (_subscribers.Count == 0)
if (subscribers.Count == 0)
{
_logger.LogWarning("No subscribers to send message to");
return;
}
var replacement = new List<(Chat from, Chat to)>();
foreach (var subscriber in _subscribers)
foreach (var subscriber in subscribers)
{
var response = await SndTxt(subscriber, message);
if (subscriber.FirstName is null)
@ -97,8 +130,8 @@ public class BotHandler : IUpdateHandler
foreach (var rep in replacement)
{
_subscribers.Remove(rep.from);
_subscribers.Add(rep.to);
subscribers.Remove(rep.from);
subscribers.Add(rep.to);
}
}
@ -107,19 +140,68 @@ public class BotHandler : IUpdateHandler
/// Send photo to all subscribers
/// </summary>
/// <param name="path"></param>
public async Task UpdatePhoto(string path)
{
/// <param name="caption">Optional message with photo</param>
/// <param name="options">Actions options</param>
public Task UpdatePhoto(string path, string? caption = null, IEnumerable<MsgOption>? options = null) =>
UpdatePhotoInt(_subscribers, path, caption, options);
if (_subscribers.Count == 0)
public Task AlertPhoto(string path, string? caption = null, IEnumerable<MsgOption>? options = null) =>
UpdatePhotoInt(_subscribersAlarm, path, caption, options);
private async Task UpdatePhotoInt(List<Chat> subscription, string path, string? caption = null, IEnumerable<MsgOption>? options = null)
{
if (subscription.Count == 0)
{
_logger.LogWarning("No subscribers to send message to");
return;
}
foreach (var subscriber in _subscribers)
foreach (var subscriber in subscription)
{
await using var stream = File.OpenRead(path);
await SndPhoto(subscriber, stream);
ReplyMarkup? replyMarkup = null;
if (options != null)
{
foreach(var opt in options)
{
if (opt.type == OptionType.Action && opt.callback != null)
{
_callbackFunctions.Add(opt.content, new(DateTime.Now, opt.callback));
}
}
// Get options in enumerations of 2
var optionsGrouped = options.Select((x, i) => (x, i)).GroupBy(x => x.i / 2)
.Select(x => x.Select(y => y.x).ToArray()).ToArray();
IEnumerable<InlineButton[]> keyboard = optionsGrouped.Select(group =>
group.Select<MsgOption, InlineButton>(opt => opt.type switch
{
OptionType.Url => new UrlButton(opt.message, new Uri(opt.content)),
OptionType.Action => new CallbackDataButton(opt.message, opt.content),
_ => throw new ArgumentOutOfRangeException(nameof(opt), opt, null)
}).ToArray()).ToArray();
replyMarkup = new InlineKeyboardMarkup(keyboard);
}
var send = new SendPhotoFile(subscriber.Id.ToString(), stream)
{
Caption = caption ?? Path.GetFileNameWithoutExtension(stream.Name),
ReplyMarkup = replyMarkup
};
await _bot.HandleAsync(send);
_ = HandleQueueExpiration();
}
}
private async Task HandleQueueExpiration()
{
//remove expired items with more than 3 hs
var expired = _callbackFunctions.Where(x => x.Value.inserted.AddHours(3) < DateTime.Now).ToArray();
foreach (var item in expired)
{
_callbackFunctions.Remove(item.Key);
_logger.LogDebug("Removed expired callback function {Key}", item.Key);
}
}
@ -198,6 +280,20 @@ public class BotHandler : IUpdateHandler
await _bot.HandleAsync(send);
}
public async Task SendPhoto(long chatId, string content, string caption)
{
await using var stream = File.OpenRead(content);
var send = new SendPhotoFile(chatId.ToString(), stream)
{
Caption = caption
};
var response = await _bot.HandleAsync(send);
if (!response.Ok)
{
_logger.LogError("Error sending photo.");
}
}
/// <summary>
/// Send photos to a specific chat
/// </summary>
@ -317,6 +413,18 @@ public class BotHandler : IUpdateHandler
_logger.LogInformation("User {User} ({id}) registered to receive messages", msg.Chat.FirstName, msg.Chat.Id);
await Respond(msg, "You are registered to receive messages every minute");
}
private async Task RegisterUserAlarm(TextMessage msg, BotCommand _)
{
if (_subscribersAlarm.Any(c => c.Id == msg.Chat.Id))
{
await Respond(msg, "You are already registered to receive Alarms");
return;
}
_subscribersAlarm.Add(msg.Chat);
_logger.LogInformation("User {User} ({id}) registered to receive Alarms", msg.Chat.FirstName, msg.Chat.Id);
await Respond(msg, "You are registered to receive alarms");
}
private Task<Response<TextMessage>> SndTxt(long id, string txt) => _bot.HandleAsync(new SendText(id.ToString(), txt));
@ -341,6 +449,9 @@ public class BotHandler : IUpdateHandler
ReplyToMessageId = message.Id
}, cst),
CallbackQueryUpdate u when u.Data is MessageCallbackQuery resp =>
HandleCallbackResponse(u, resp),
_ => Task.CompletedTask
};
}catch (Exception e)
@ -350,5 +461,26 @@ public class BotHandler : IUpdateHandler
}
}
private async Task HandleCallbackResponse(CallbackQueryUpdate update, MessageCallbackQuery response)
{
_logger.LogInformation("Callback message received: {id} {Response}", update.Id, response.Data);
if (!_callbackFunctions.TryGetValue(response.Data, out var queueCallback))
{
_logger.LogError("Callback function not found for {Response}", response.Data);
_ = _bot.HandleAsync(new AnswerCallbackQuery(response.Id, "Option expired"));
return;
}
var chatId = response.Message.Chat.Id;
var answer = await queueCallback.callback(response.Data, chatId);
var r = await _bot.HandleAsync(new AnswerCallbackQuery(response.Id, answer));
if (!r.Ok)
{
_logger.LogError("Error sending callback response: {Error}", r.Failure);
}
}
}

View File

@ -0,0 +1,9 @@
namespace CasaBotApp.TelegramBot;
public record MsgOption(OptionType type, string message, string content, Func<string, long, Task<string>>? callback = null);
public enum OptionType
{
Url,
Action
}

View File

@ -1,7 +1,8 @@
namespace CasaBotApp;
namespace CasaBotApp.TelegramBot;
public class TelegramOptions
{
public string? BotToken { get; set; }
public long[] SubscribedChatIds { get; set; } = [];
public long[] SubscribedAlarmsChatIds { get; set; } = [];
}

View File

@ -35,5 +35,8 @@
}
},
"RemoveOriginalFiles": true,
"RemoveDetectionFiles": false
"RemoveDetectionFiles": false,
"ControlServer": {
"port": 9003
}
}

View File

@ -36,5 +36,8 @@
},
"RemoveOriginalFiles": false,
"RemoveDetectionFiles": false
},
"ControlServer": {
"port": 9003
}
}

View File

@ -87,7 +87,7 @@ time-post-event = 2s
# a motion event to be triggered. Lower values require less movement, and are
# more sensitive to motion. If too high, some movement may not be detected,
# while too low of a threshold can result in false detection events.
threshold = 2
threshold = 1.7
# Scores of this amount or higher are ignored. 255.0 is the maximum score, so
# values greater than 255.0 will disable the filter.

View File

@ -0,0 +1,181 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text;
namespace ControlServer;
public class ControlServer : IControlServer
{
private readonly ILogger<ControlServer> _logger;
private readonly ControlServerOptions _options;
private readonly HttpListener _httpListener;
private readonly Dictionary<long, SensorEvent> _events = new();
private Func<SensorEvent, Task>? _onEventRecived;
private bool _disarmRequestPending;
public ControlServer(ILogger<ControlServer> logger, IOptions<ControlServerOptions> options)
{
_logger = logger;
_options = options.Value;
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://+:{_options.port}/");
}
public async Task StartAsync(CancellationToken ct)
{
try
{
_httpListener.Start();
_logger.LogInformation("Http server listening on port {Port}", _options.port);
}catch (Exception e)
{
_logger.LogError("Error starting HTTP server: {Message}", e.Message);
return;
}
while (!ct.IsCancellationRequested)
{
var ctx = await _httpListener.GetContextAsync();
// _logger.LogDebug("Got a request");
var request = ctx.Request;
var response = ctx.Response;
// _logger.LogInformation("Url: {Url} | Method {method}", request.Url, request.HttpMethod);
//Query params
var eId = request.QueryString["eventId"];
var eventType = request.QueryString["eventType"];
var eventData = request.QueryString["eventData"];
var eventId = long.TryParse(eId, out var id) ? id : 0;
var eventTypeEnum = Enum.TryParse<EventType>(eventType, out var type) ? type : EventType.Unknown;
var eventDataString = eventData ?? string.Empty;
var sensorEvent = new SensorEvent(eventId, eventTypeEnum, eventDataString);
if (sensorEvent.Type == EventType.Update)
{
HandleUpdate(response);
response.Close();
continue;
}
_logger.LogInformation("Event Received: {event}", sensorEvent);
var notify = HandleSensorEvent(sensorEvent);
response.StatusCode = 200;
response.ContentType = "text/plain";
var message = sensorEvent.Authorization == Authorization.Unauthorized ? "UNAUTHORIZED" : "AUTHORIZE_ENTRANCE";
response.ContentLength64 = Encoding.UTF8.GetByteCount(message);
await using (var writer = new StreamWriter(response.OutputStream))
{
await writer.WriteAsync(message);
}
response.Close();
if (notify)
{
_ = _onEventRecived?.Invoke(sensorEvent);
}
}
}
private readonly List<Card> _cards = new()
{
new Card("1", "Card 1"),
new Card("2", "Card 2"),
new Card("3", "Card 3"),
new Card("4", "Card 4"),
new Card("5", "Card 5"),
};
private void HandleUpdate(HttpListenerResponse response)
{
var updateResponse = new UpdateResponse(_disarmRequestPending, _cards.ToArray());
_disarmRequestPending = false;
response.StatusCode = 200;
response.ContentType = "application/json";
// response.ContentType = "text/plain";
var message = System.Text.Json.JsonSerializer.Serialize(updateResponse);
response.ContentLength64 = Encoding.UTF8.GetByteCount(message);
using var writer = new StreamWriter(response.OutputStream);
writer.Write(message);
}
public record UpdateResponse(bool disarm, Card[] cards);
public record Card(string id, string name);
private bool HandleSensorEvent(SensorEvent se)
{
if (se.Type == EventType.Update && _disarmRequestPending)
{
_disarmRequestPending = false;
se.Authorization = Authorization.Authorized;
return false;
}
_events.TryGetValue(se.EventId, out var storedEvent);
if (storedEvent is null)
{
//New One
if (se.Type == EventType.DisarmedEntrance)
{
_disarmRequestPending = false;
se.Authorization = Authorization.Authorized;
_events.Add(se.EventId, se);
return true;
}
if (se.Type == EventType.Fired)
{
//Check pending desarmed.
se.Authorization = _disarmRequestPending ? Authorization.Authorized : Authorization.Unauthorized;
_disarmRequestPending = false;
_events.Add(se.EventId, se);
return true;
}
_events.Add(se.EventId, new SensorEvent(se.EventId, se.Type, se.Data));
return false;
}
//Already stored
if (se.Type == EventType.EventUpdate && (_disarmRequestPending || storedEvent.Authorization == Authorization.Authorized) )
{
_disarmRequestPending = false;
storedEvent.Authorization= Authorization.Authorized;
se.Authorization = Authorization.Authorized;
return false;
}
return false;
}
public Task StopAsync(CancellationToken ct)
{
// _httpListener.EndGetContext(null);
// _httpListener.Close();
_httpListener.Stop();
return Task.CompletedTask;
}
public void OnEvent(Func<SensorEvent, Task> onEventRecived)
{
_onEventRecived = onEventRecived;
}
public void AuthorizeEvent(long eventId)
{
if (_events.TryGetValue(eventId, out var sensorEvent))
{
sensorEvent.Authorization = Authorization.Authorized;
}
}
public void RequestDisarm()
{
_disarmRequestPending = true;
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.4" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace ControlServer;
public class ControlServerOptions
{
public int port { get; set; }
}

View File

@ -0,0 +1,10 @@
using Microsoft.Extensions.Hosting;
namespace ControlServer;
public interface IControlServer : IHostedService
{
public void OnEvent(Func<SensorEvent, Task> onEventRecived);
public void AuthorizeEvent(long eventId);
public void RequestDisarm();
}

View File

@ -0,0 +1,21 @@
namespace ControlServer;
public record SensorEvent(long EventId, EventType Type, string Data)
{
public Authorization Authorization { get; set; }
};
public enum EventType
{
Fired,
DisarmedEntrance,
EventUpdate,
Update,
Unknown
}
public enum Authorization
{
Unauthorized,
Authorized
}