Sun Source Management
Sun Sources are the core domain entity in Ampra — each represents a monitored solar energy installation with its associated telemetry, configuration, and connection credentials.
Concept
A Sun Source is a physical solar energy system (panels, inverter, battery, grid connection) that generates real-time telemetry data. Each source:
- Belongs to exactly one user (ownership-based multi-tenancy)
- May optionally belong to a Power Group for organizational hierarchy
- Connects via either MQTT or Webhook for data ingestion
- Has independently configurable sharing, weather, prediction, and ROI settings
Lifecycle
Creation
MQTT Connection Type
When a source is created with ConnectionType = MQTT:
source.MqttTopic = $"ampra/sources/{source.Id}/data";
source.MqttApiKey = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); // 64-char hex
The device must authenticate with the EMQX broker using:
- Client ID:
src-{sunSourceId} - Username:
src-{sunSourceId} - Password: The generated
MqttApiKey - Topic:
ampra/sources/{sunSourceId}/data(publish only)
Webhook Connection Type
When a source is created with ConnectionType = Webhook:
source.WebhookSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
source.WebhookUrl = $"https://{apiDomain}/api/webhook/sunsource/{source.Id}";
The device sends HTTP POST requests to the webhook URL with:
- Header:
X-Webhook-SecretcontainingHMAC-SHA256(body, secret) - Body: JSON telemetry payload
Full Creation Implementation
public async Task<SunSourceDto> CreateSunSourceAsync(CreateSunSourceRequest request, string userId)
{
var sunSource = request.Adapt<SunSource>();
sunSource.UserId = userId;
sunSource.ConnectionType = connectionType;
sunSource.Latitude = request.Latitude is >= -90 and <= 90 ? request.Latitude : null;
sunSource.Longitude = request.Longitude is >= -180 and <= 180 ? request.Longitude : null;
sunSource.Currency = request.Currency ?? Defaults.Currency;
if (connectionType == SunSourceConnectionType.MQTT)
{
sunSource.MqttTopic = MqttTopics.ForSource(sunSource.Id);
sunSource.MqttApiKey = GenerateApiKey();
}
else if (connectionType == SunSourceConnectionType.Webhook)
{
sunSource.WebhookSecret = request.WebhookSecret ?? GenerateWebhookSecret();
sunSource.WebhookUrl = GenerateWebhookUrl(sunSource.Id);
}
// Optional initial kWh price entry
if (request.InitialKwhPrice.HasValue && request.InitialKwhPrice.Value > 0)
{
_context.KwhPriceHistories.Add(new KwhPriceHistory
{
SunSourceId = sunSource.Id,
PricePerKwh = (decimal)request.InitialKwhPrice.Value,
EffectiveFrom = DateTime.UtcNow
});
}
_context.SunSources.Add(sunSource);
await _context.SaveChangesAsync();
// ...
}
Key Generation (CSPRNG)
private string GenerateWebhookSecret()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToBase64String(bytes); // 44-char Base64
}
private static string GenerateApiKey()
{
var bytes = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
return Convert.ToHexStringLower(bytes); // 64-char hex
}
Initial KWh Price
If InitialKwhPrice is provided during creation, a KwhPriceHistory entry is automatically created with EffectiveFrom set to the current date.
Connection Details
Connection credentials are security-sensitive and are not included in standard GET responses. They are only returned when explicitly requested:
GET /api/sunsources/{id}?includeConnectionDetails=true
Connection Details Builder
private SunSourceConnectionDetailsDto BuildConnectionDetails(SunSource source)
{
if (source.ConnectionType == SunSourceConnectionType.MQTT)
{
return new SunSourceConnectionDetailsDto
{
MqttBrokerHost = _mqttSettings.Host,
MqttBrokerPort = _mqttSettings.Port,
MqttClientId = $"{MqttTopics.ClientIdPrefix}{source.Id}",
MqttApiKey = source.MqttApiKey,
MqttTopic = source.MqttTopic
};
}
return new SunSourceConnectionDetailsDto
{
WebhookUrl = source.WebhookUrl,
WebhookSecret = source.WebhookSecret
};
}
The returned SunSourceConnectionDetailsDto includes:
| Field | MQTT | Webhook |
|---|---|---|
MqttBrokerHost | Public broker hostname | — |
MqttBrokerPort | 1883 | — |
MqttClientId | src-{id} | — |
MqttApiKey | 64-char hex key | — |
MqttTopic | ampra/sources/{id}/data | — |
WebhookUrl | — | Full POST URL |
WebhookSecret | — | Base64 secret |
Credential Rotation
MQTT API Key Regeneration
POST /api/sunsources/{id}/regenerate-mqtt-key
→ { mqttApiKey: "new-64-char-hex-key" }
Generates a fresh 64-character hex API key. The old key is immediately invalidated — the device must be reconnected with the new credential.
Webhook Secret Rotation
POST /api/sunsources/{id}/rotate-secret
→ { webhookSecret: "new-base64-secret" }
Generates a fresh 32-byte Base64-encoded HMAC secret. Webhook payloads signed with the old secret will be rejected.
Power Groups
Power Groups provide a hierarchical organizational layer:
Behavior
| Action | Effect |
|---|---|
| Delete Power Group | Sources become ungrouped (FK SET NULL) |
| Delete User | All groups and sources cascade-deleted |
| Move Source | Update PowerGroupId via PUT |
Group Metadata
Each Power Group includes:
- Name (max 100 chars, required)
- Description (max 500 chars, optional)
- LogoUrl (max 500 chars, optional — stored in MinIO)
- Computed SunSourceCount in API responses
Deletion
When a Sun Source is deleted:
- PostgreSQL: Entity and all related records cascade-deleted (weather, pricing, sharing)
- MongoDB: All telemetry documents for this source are bulk-deleted across 4 collections
- Connection credentials are immediately invalidated
Cascade Delete Implementation
public async Task<bool> DeleteSunSourceAsync(Guid id, string userId)
{
var sunSource = await _context.SunSources
.FirstOrDefaultAsync(ss => ss.Id == id && ss.UserId == userId);
if (sunSource == null) return false;
// PostgreSQL cascade delete (weather, pricing, sharing follow via FK)
_context.SunSources.Remove(sunSource);
await _context.SaveChangesAsync();
// MongoDB cleanup — 4 collections
var filter = Builders<BsonDocument>.Filter.Eq("sunSourceId", id.ToString());
var normalizedCollection = _mongoDatabase
.GetCollection<NormalizedSunSourceData>(MongoCollections.NormalizedSunSourceData);
await normalizedCollection.DeleteManyAsync(d => d.SunSourceId == id);
await _mongoDatabase.GetCollection<BsonDocument>("predictions")
.DeleteManyAsync(filter);
await _mongoDatabase.GetCollection<BsonDocument>("model_metadata")
.DeleteManyAsync(filter);
await _mongoDatabase.GetCollection<BsonDocument>("weather_data")
.DeleteManyAsync(filter);
return true;
}
Telemetry Data Normalization
The ingestion layer normalizes flexible JSON payloads from diverse device manufacturers using a comprehensive field alias mapping system:
Field Alias Examples
| Normalized Field | Accepted Aliases |
|---|---|
stateOfCharge | soc, state_of_charge, batteryLevel, battery_level, batteryPercent |
solarPower | pvPower, pv_power, solar_power, panelPower, pvWatts |
loadPower | load_power, acOutputPower, ac_output_power, consumption |
gridVoltage | grid_voltage, mainsVoltage, utilityVoltage |
batteryTemperature | battery_temperature, battTemp, batt_temp |
operatingMode | mode, operating_mode, workMode, work_mode, inverterMode |
The full alias map covers 50+ field variants across common solar inverter brands.
Validation & Filtering
Value Range Validation
Every numeric field is validated against physical bounds before storage:
private static readonly Dictionary<string, (double Min, double Max)> ValueRanges = new()
{
["stateOfCharge"] = (0, 100),
["batteryVoltage"] = (0, 100),
["batteryCurrent"] = (-200, 200),
["batteryTemperature"] = (-40, 80),
["solarPower"] = (0, 100_000),
["loadPower"] = (0, 100_000),
["gridVoltage"] = (0, 500),
["totalEnergyProduced"] = (0, 10_000_000),
// ... 16 more fields
};
Safe Double Extraction
The normalizer handles JSON numbers, string-encoded numbers, NaN/Infinity rejection:
private static double? GetSafeDouble(Dictionary<string, JsonElement> props, string canonicalName)
{
var el = FindProperty(props, canonicalName);
if (!el.HasValue) return null;
double value;
if (el.Value.ValueKind == JsonValueKind.Number)
value = el.Value.GetDouble();
else if (el.Value.ValueKind == JsonValueKind.String &&
double.TryParse(el.Value.GetString(), NumberStyles.Float,
CultureInfo.InvariantCulture, out var parsed))
value = parsed;
else
return null;
// Reject non-finite values
if (double.IsNaN(value) || double.IsInfinity(value))
return null;
// Validate against physical bounds
if (ValueRanges.TryGetValue(canonicalName, out var range) &&
(value < range.Min || value > range.Max))
return null;
return value;
}
| Check | Rule | Action |
|---|---|---|
| Numeric range | Values must be within physically plausible ranges | Out-of-range values set to null |
| Throttling | Minimum 10 seconds between messages per source | Earlier messages within window are dropped |
| Deduplication | 30-second deduplication window | Duplicate timestamps rejected |
| Raw data preservation | Original payload stored as BSON | Available for audit and reprocessing |
Public Sharing
Each sun source can be configured for granular public sharing:
| Flag | Controls | Public Endpoint |
|---|---|---|
IsEnabled | Master toggle — all sharing off if false | — |
ShareMonitor | Real-time and historical telemetry | /api/sharing/public/{id}/metrics |
ShareWeather | Weather forecast data | /api/sharing/public/{id}/weather |
ShareForecast | ML prediction results | /api/sharing/public/{id}/forecast |
ShareReturns | ROI and financial data | /api/sharing/public/{id}/roi |
Public endpoints are [AllowAnonymous] and do not require authentication. Each endpoint validates the sharing configuration before returning data.