Skip to main content

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-Secret containing HMAC-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:

FieldMQTTWebhook
MqttBrokerHostPublic broker hostname
MqttBrokerPort1883
MqttClientIdsrc-{id}
MqttApiKey64-char hex key
MqttTopicampra/sources/{id}/data
WebhookUrlFull POST URL
WebhookSecretBase64 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

ActionEffect
Delete Power GroupSources become ungrouped (FK SET NULL)
Delete UserAll groups and sources cascade-deleted
Move SourceUpdate 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:

  1. PostgreSQL: Entity and all related records cascade-deleted (weather, pricing, sharing)
  2. MongoDB: All telemetry documents for this source are bulk-deleted across 4 collections
  3. 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 FieldAccepted Aliases
stateOfChargesoc, state_of_charge, batteryLevel, battery_level, batteryPercent
solarPowerpvPower, pv_power, solar_power, panelPower, pvWatts
loadPowerload_power, acOutputPower, ac_output_power, consumption
gridVoltagegrid_voltage, mainsVoltage, utilityVoltage
batteryTemperaturebattery_temperature, battTemp, batt_temp
operatingModemode, 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;
}
CheckRuleAction
Numeric rangeValues must be within physically plausible rangesOut-of-range values set to null
ThrottlingMinimum 10 seconds between messages per sourceEarlier messages within window are dropped
Deduplication30-second deduplication windowDuplicate timestamps rejected
Raw data preservationOriginal payload stored as BSONAvailable for audit and reprocessing

Public Sharing

Each sun source can be configured for granular public sharing:

FlagControlsPublic Endpoint
IsEnabledMaster toggle — all sharing off if false
ShareMonitorReal-time and historical telemetry/api/sharing/public/{id}/metrics
ShareWeatherWeather forecast data/api/sharing/public/{id}/weather
ShareForecastML prediction results/api/sharing/public/{id}/forecast
ShareReturnsROI 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.