Initial Commit
This commit is contained in:
21
OtaFleet.Api/Data/Entities/Deployment.cs
Normal file
21
OtaFleet.Api/Data/Entities/Deployment.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OtaFleet.Api.Data.Entities;
|
||||
|
||||
public class Deployment
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int UpdateId { get; set; }
|
||||
|
||||
[ForeignKey("UpdateId")]
|
||||
public FirmwareUpdate Update { get; set; } = null!;
|
||||
|
||||
public string? TargetVin { get; set; }
|
||||
|
||||
public int? TargetGroupId { get; set; }
|
||||
|
||||
public string Status { get; set; } = "Pending"; // Pending, InProgress, Completed, Failed
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
18
OtaFleet.Api/Data/Entities/FirmwareUpdate.cs
Normal file
18
OtaFleet.Api/Data/Entities/FirmwareUpdate.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OtaFleet.Api.Data.Entities;
|
||||
|
||||
public class FirmwareUpdate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
21
OtaFleet.Api/Data/Entities/Vehicle.cs
Normal file
21
OtaFleet.Api/Data/Entities/Vehicle.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace OtaFleet.Api.Data.Entities;
|
||||
|
||||
public class Vehicle
|
||||
{
|
||||
[Key]
|
||||
public string Vin { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "Offline"; // Online, Offline, Updating
|
||||
|
||||
public string CurrentVersion { get; set; } = "1.0.0";
|
||||
|
||||
public DateTime LastHeartbeat { get; set; }
|
||||
|
||||
public int? GroupId { get; set; }
|
||||
|
||||
[ForeignKey("GroupId")]
|
||||
public VehicleGroup? Group { get; set; }
|
||||
}
|
||||
15
OtaFleet.Api/Data/Entities/VehicleGroup.cs
Normal file
15
OtaFleet.Api/Data/Entities/VehicleGroup.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OtaFleet.Api.Data.Entities;
|
||||
|
||||
public class VehicleGroup
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public List<Vehicle> Vehicles { get; set; } = new();
|
||||
}
|
||||
25
OtaFleet.Api/Data/OtaDbContext.cs
Normal file
25
OtaFleet.Api/Data/OtaDbContext.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OtaFleet.Api.Data.Entities;
|
||||
|
||||
namespace OtaFleet.Api.Data;
|
||||
|
||||
public class OtaDbContext : DbContext
|
||||
{
|
||||
public OtaDbContext(DbContextOptions<OtaDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Vehicle> Vehicles => Set<Vehicle>();
|
||||
public DbSet<VehicleGroup> VehicleGroups => Set<VehicleGroup>();
|
||||
public DbSet<FirmwareUpdate> FirmwareUpdates => Set<FirmwareUpdate>();
|
||||
public DbSet<Deployment> Deployments => Set<Deployment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Vehicle>()
|
||||
.HasOne(v => v.Group)
|
||||
.WithMany(g => g.Vehicles)
|
||||
.HasForeignKey(v => v.GroupId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
141
OtaFleet.Api/Endpoints/AdminEndpoints.cs
Normal file
141
OtaFleet.Api/Endpoints/AdminEndpoints.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OtaFleet.Api.Data;
|
||||
using OtaFleet.Api.Data.Entities;
|
||||
|
||||
namespace OtaFleet.Api.Endpoints;
|
||||
|
||||
public static class AdminEndpoints
|
||||
{
|
||||
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/admin");
|
||||
|
||||
// Vehicles
|
||||
group.MapGet("/vehicles", async (OtaDbContext db) =>
|
||||
{
|
||||
return await db.Vehicles.Include(v => v.Group).ToListAsync();
|
||||
});
|
||||
|
||||
// Groups
|
||||
group.MapGet("/groups", async (OtaDbContext db) =>
|
||||
{
|
||||
return await db.VehicleGroups.Include(g => g.Vehicles).ToListAsync();
|
||||
});
|
||||
|
||||
group.MapPost("/groups", async (OtaDbContext db, CreateGroupRequest request) =>
|
||||
{
|
||||
var group = new VehicleGroup
|
||||
{
|
||||
Name = request.Name,
|
||||
Description = request.Description
|
||||
};
|
||||
db.VehicleGroups.Add(group);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/admin/groups/{group.Id}", group);
|
||||
});
|
||||
|
||||
group.MapPut("/groups/{id}", async (OtaDbContext db, int id, UpdateGroupRequest request) =>
|
||||
{
|
||||
var group = await db.VehicleGroups.FindAsync(id);
|
||||
if (group == null) return Results.NotFound();
|
||||
|
||||
group.Name = request.Name;
|
||||
group.Description = request.Description;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(group);
|
||||
});
|
||||
|
||||
group.MapPost("/groups/{groupId}/vehicles", async (OtaDbContext db, int groupId, AssignVehicleRequest request) =>
|
||||
{
|
||||
var vehicle = await db.Vehicles.FindAsync(request.Vin);
|
||||
if (vehicle == null) return Results.NotFound("Vehicle not found");
|
||||
|
||||
var group = await db.VehicleGroups.FindAsync(groupId);
|
||||
if (group == null) return Results.NotFound("Group not found");
|
||||
|
||||
vehicle.GroupId = groupId;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
// Updates
|
||||
group.MapPost("/updates", async (OtaDbContext db, HttpRequest request) =>
|
||||
{
|
||||
if (!request.HasFormContentType)
|
||||
return Results.BadRequest("Expected multipart/form-data");
|
||||
|
||||
var form = await request.ReadFormAsync();
|
||||
var file = form.Files["file"];
|
||||
var version = form["version"];
|
||||
var description = form["description"];
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
return Results.BadRequest("No file uploaded");
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return Results.BadRequest("Version is required");
|
||||
|
||||
// Save file
|
||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
Directory.CreateDirectory(uploadsDir);
|
||||
var filePath = Path.Combine(uploadsDir, $"{version}_{file.FileName}");
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
var update = new FirmwareUpdate
|
||||
{
|
||||
Version = version!,
|
||||
Description = description,
|
||||
FilePath = filePath,
|
||||
UploadedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.FirmwareUpdates.Add(update);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(update);
|
||||
});
|
||||
|
||||
group.MapGet("/updates", async (OtaDbContext db) =>
|
||||
{
|
||||
return await db.FirmwareUpdates.OrderByDescending(u => u.UploadedAt).ToListAsync();
|
||||
});
|
||||
|
||||
// Deployments
|
||||
group.MapPost("/deployments", async (OtaDbContext db, CreateDeploymentRequest request) =>
|
||||
{
|
||||
var update = await db.FirmwareUpdates.FindAsync(request.UpdateId);
|
||||
if (update == null) return Results.NotFound("Update not found");
|
||||
|
||||
var deployment = new Deployment
|
||||
{
|
||||
UpdateId = request.UpdateId,
|
||||
TargetVin = request.TargetVin,
|
||||
TargetGroupId = request.TargetGroupId,
|
||||
Status = "Pending",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Deployments.Add(deployment);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/admin/deployments/{deployment.Id}", deployment);
|
||||
});
|
||||
|
||||
group.MapGet("/deployments", async (OtaDbContext db) =>
|
||||
{
|
||||
return await db.Deployments
|
||||
.Include(d => d.Update)
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.ToListAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateGroupRequest(string Name, string? Description);
|
||||
public record UpdateGroupRequest(string Name, string? Description);
|
||||
public record AssignVehicleRequest(string Vin);
|
||||
public record CreateDeploymentRequest(int UpdateId, string? TargetVin, int? TargetGroupId);
|
||||
98
OtaFleet.Api/Endpoints/VehicleEndpoints.cs
Normal file
98
OtaFleet.Api/Endpoints/VehicleEndpoints.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OtaFleet.Api.Data;
|
||||
using OtaFleet.Api.Data.Entities;
|
||||
|
||||
namespace OtaFleet.Api.Endpoints;
|
||||
|
||||
public static class VehicleEndpoints
|
||||
{
|
||||
public static void MapVehicleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/vehicles");
|
||||
|
||||
group.MapPost("/register", async (OtaDbContext db, RegisterVehicleRequest request) =>
|
||||
{
|
||||
var vehicle = await db.Vehicles.FindAsync(request.Vin);
|
||||
if (vehicle == null)
|
||||
{
|
||||
vehicle = new Vehicle
|
||||
{
|
||||
Vin = request.Vin,
|
||||
Status = "Online",
|
||||
CurrentVersion = request.CurrentVersion,
|
||||
LastHeartbeat = DateTime.UtcNow
|
||||
};
|
||||
db.Vehicles.Add(vehicle);
|
||||
}
|
||||
else
|
||||
{
|
||||
vehicle.Status = "Online";
|
||||
vehicle.CurrentVersion = request.CurrentVersion;
|
||||
vehicle.LastHeartbeat = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(vehicle);
|
||||
});
|
||||
|
||||
group.MapPost("/heartbeat", async (OtaDbContext db, HeartbeatRequest request) =>
|
||||
{
|
||||
var vehicle = await db.Vehicles.FindAsync(request.Vin);
|
||||
if (vehicle == null) return Results.NotFound();
|
||||
|
||||
vehicle.LastHeartbeat = DateTime.UtcNow;
|
||||
vehicle.Status = request.Status;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
group.MapGet("/updates", async (OtaDbContext db, string vin) =>
|
||||
{
|
||||
var vehicle = await db.Vehicles.Include(v => v.Group).FirstOrDefaultAsync(v => v.Vin == vin);
|
||||
if (vehicle == null) return Results.NotFound();
|
||||
|
||||
// Find deployment for this vehicle or its group
|
||||
var deployment = await db.Deployments
|
||||
.Include(d => d.Update)
|
||||
.Where(d => (d.TargetVin == vin || (vehicle.GroupId.HasValue && d.TargetGroupId == vehicle.GroupId)) && d.Status != "Completed") // Simple logic, can be improved
|
||||
.OrderByDescending(d => d.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (deployment == null) return Results.NoContent();
|
||||
|
||||
// Check if vehicle already has this version
|
||||
if (deployment.Update.Version == vehicle.CurrentVersion) return Results.NoContent();
|
||||
|
||||
return Results.Ok(new VehicleUpdateResponse
|
||||
{
|
||||
UpdateId = deployment.Update.Id,
|
||||
Version = deployment.Update.Version,
|
||||
Description = deployment.Update.Description,
|
||||
DownloadUrl = $"/api/vehicles/updates/{deployment.Update.Id}/download"
|
||||
});
|
||||
});
|
||||
|
||||
group.MapGet("/updates/{id}/download", async (OtaDbContext db, int id) =>
|
||||
{
|
||||
var update = await db.FirmwareUpdates.FindAsync(id);
|
||||
if (update == null) return Results.NotFound();
|
||||
|
||||
if (!File.Exists(update.FilePath)) return Results.NotFound("File not found on server");
|
||||
|
||||
var fileName = Path.GetFileName(update.FilePath);
|
||||
var mimeType = "application/octet-stream";
|
||||
|
||||
return Results.File(update.FilePath, mimeType, fileName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record RegisterVehicleRequest(string Vin, string CurrentVersion);
|
||||
public record HeartbeatRequest(string Vin, string Status);
|
||||
public record VehicleUpdateResponse
|
||||
{
|
||||
public int UpdateId { get; set; }
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string DownloadUrl { get; set; } = string.Empty;
|
||||
}
|
||||
19
OtaFleet.Api/OtaFleet.Api.csproj
Normal file
19
OtaFleet.Api/OtaFleet.Api.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
61
OtaFleet.Api/Program.cs
Normal file
61
OtaFleet.Api/Program.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OtaFleet.Api.Data;
|
||||
using OtaFleet.Api.Endpoints;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Fix object cycle depth for JSON serialization
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
|
||||
// Helper to disable CORS for local dev
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<OtaDbContext>(options =>
|
||||
options.UseSqlite("Data Source=ota.db"));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
// app.UseHttpsRedirection();
|
||||
|
||||
// Map endpoints
|
||||
app.MapVehicleEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
|
||||
// Create uploads directory if not exists
|
||||
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
|
||||
|
||||
// Ensure DB is created
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtaDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
23
OtaFleet.Api/Properties/launchSettings.json
Normal file
23
OtaFleet.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:5001;http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
OtaFleet.Api/appsettings.Development.json
Normal file
8
OtaFleet.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
OtaFleet.Api/appsettings.json
Normal file
9
OtaFleet.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
BIN
OtaFleet.Api/ota.db
Normal file
BIN
OtaFleet.Api/ota.db
Normal file
Binary file not shown.
9
OtaFleet.Api/uploads/1.0.1_newFW.bin
Normal file
9
OtaFleet.Api/uploads/1.0.1_newFW.bin
Normal file
@@ -0,0 +1,9 @@
|
||||
[Interface]
|
||||
PrivateKey = +CaAYIVmKuE/nKz1f/J5PSZMoZ5ZF5geA1cBrNWxW20=
|
||||
Address = 192.168.2.3/32
|
||||
DNS = 192.168.2.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = aNRAa8n1Jl9CnfbQECtCpwq8750jj/+Ahvz38o4U0l8=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = hal9000.damnserver.com:51820
|
||||
9
OtaFleet.Api/uploads/2.0.0_newerFW.bin
Normal file
9
OtaFleet.Api/uploads/2.0.0_newerFW.bin
Normal file
@@ -0,0 +1,9 @@
|
||||
[Interface]
|
||||
PrivateKey = +CaAYIVmKuE/nKz1f/J5PSZMoZ5ZF5geA1cBrNWxW20=
|
||||
Address = 192.168.2.3/32
|
||||
DNS = 192.168.2.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = aNRAa8n1Jl9CnfbQECtCpwq8750jj/+Ahvz38o4U0l8=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = hal9000.damnserver.com:51820
|
||||
Reference in New Issue
Block a user