Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.scope;

import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind;

import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;

/**
* Immutable value object for one parsed NAESB ESPI 4.0 OAuth scope string.
*
* <p>ESPI scope grammar is a {@code ;}-delimited list of {@code key=value} terms, e.g.
* {@code FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13}. The {@code FB}
* term carries a {@code _}-delimited set of Function Block ids.</p>
*
* <p>The parser is intentionally <strong>FB-agnostic</strong>: it carries whatever FB ids appear
* in the string (including spec-deprecated or future ids) without consulting the catalog. Semantic
* questions &mdash; category, commodity {@link ServiceKind} &mdash; are answered on demand via
* {@link FunctionBlock}. Unrecognized non-{@code FB} terms are preserved in
* {@link #additionalParameters()} so no information is lost.</p>
*
* <p>This object holds <em>no</em> OAuth, HTTP, persistence, or resource knowledge. It is the shared
* foundation for grant&rarr;subscription candidate resolution and resource-server enforcement (#122).</p>
*
* @param raw the original scope string as parsed
* @param functionBlocks FB ids from the {@code FB=} term, sorted ascending and immutable
* @param intervalDuration {@code IntervalDuration} term in seconds, or {@code null} if absent
* @param blockDuration {@code BlockDuration} term (e.g. {@code monthly}), or {@code null} if absent
* @param historyLength {@code HistoryLength} term, or {@code null} if absent
* @param additionalParameters any other {@code key=value} terms (and bare tokens, with empty value),
* insertion-ordered and immutable
* @see FunctionBlock
* @see FunctionBlockCategory
*/
public record EspiScope(
String raw,
Set<Integer> functionBlocks,
Integer intervalDuration,
String blockDuration,
Integer historyLength,
Map<String, String> additionalParameters
) {

/** Canonical constructor; defensively copies collections into sorted/insertion-ordered immutable views. */
public EspiScope {
functionBlocks = functionBlocks == null
? Collections.emptySortedSet()
: Collections.unmodifiableSortedSet(new TreeSet<>(functionBlocks));
additionalParameters = additionalParameters == null
? Collections.emptyMap()
: Collections.unmodifiableMap(new LinkedHashMap<>(additionalParameters));
}

/**
* Parse an ESPI scope string into an {@code EspiScope}.
*
* <p>Tolerant of surrounding whitespace, stray/empty {@code ;} segments, and mixed-case term
* keys. The recognized keys ({@code FB}, {@code IntervalDuration}, {@code BlockDuration},
* {@code HistoryLength}) are matched case-insensitively; every other {@code key=value} term is
* preserved in {@link #additionalParameters()}, and a bare token with no {@code =} is preserved
* with an empty value.</p>
*
* @param scope the scope string (e.g. {@code FB=4_5_15;IntervalDuration=3600})
* @return the parsed value object
* @throws IllegalArgumentException if {@code scope} is null/blank, or an {@code FB},
* {@code IntervalDuration}, or {@code HistoryLength} value is
* not an integer
*/
public static EspiScope parse(String scope) {
if (scope == null || scope.isBlank()) {
throw new IllegalArgumentException("ESPI scope must not be null or blank");
}

Set<Integer> fbs = new TreeSet<>();
Integer intervalDuration = null;
String blockDuration = null;
Integer historyLength = null;
Map<String, String> extras = new LinkedHashMap<>();

for (String segment : scope.split(";")) {
String term = segment.trim();
if (term.isEmpty()) {
continue;
}
int eq = term.indexOf('=');
if (eq < 0) {
extras.put(term, "");
continue;
}
String key = term.substring(0, eq).trim();
String value = term.substring(eq + 1).trim();
switch (key.toLowerCase(Locale.ROOT)) {
case "fb" -> {
for (String token : value.split("_")) {
String fb = token.trim();
if (!fb.isEmpty()) {
fbs.add(parseIntTerm(key, fb));
}
}
}
case "intervalduration" -> intervalDuration = parseIntTerm(key, value);
case "historylength" -> historyLength = parseIntTerm(key, value);
case "blockduration" -> blockDuration = value;
default -> extras.put(key, value);
}
}

return new EspiScope(scope, fbs, intervalDuration, blockDuration, historyLength, extras);
}

private static int parseIntTerm(String key, String value) {
try {
return Integer.parseInt(value);
}
catch (NumberFormatException e) {
throw new IllegalArgumentException(
"ESPI scope term '" + key + "' expected an integer but was '" + value + "'", e);
}
}

/** @return whether the given FB id is present in this scope. */
public boolean containsFunctionBlock(int functionBlock) {
return functionBlocks.contains(functionBlock);
}

/**
* @param category the category to filter by
* @return the FB ids in this scope that belong to {@code category}, sorted and immutable
*/
public Set<Integer> functionBlocksIn(FunctionBlockCategory category) {
TreeSet<Integer> result = new TreeSet<>();
for (int fb : functionBlocks) {
if (FunctionBlock.categoryOf(fb) == category) {
result.add(fb);
}
}
return Collections.unmodifiableSortedSet(result);
}

/**
* @return the distinct {@link ServiceKind}s selected by the commodity FBs in this scope.
* Commodity FBs with no XSD-defined kind (FB 29 / Temperature) contribute none.
*/
public Set<ServiceKind> commodityServiceKinds() {
Set<ServiceKind> kinds = EnumSet.noneOf(ServiceKind.class);
for (int fb : functionBlocks) {
FunctionBlock.serviceKindOf(fb).ifPresent(kinds::add);
}
return Collections.unmodifiableSet(kinds);
}

/** @return whether this scope grants any customer/PII FB (54-62). */
public boolean includesCustomerPii() {
return !functionBlocksIn(FunctionBlockCategory.CUSTOMER_PII).isEmpty();
}

/** @return whether this scope grants any energy data-shape FB (12, 15, 16, 17, 27, 28). */
public boolean includesEnergyData() {
return !functionBlocksIn(FunctionBlockCategory.ENERGY_DATA_SHAPE).isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.scope;

import org.greenbuttonalliance.espi.common.domain.usage.enums.ServiceKind;

import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
* Authoritative catalog of NAESB ESPI 4.0 OAuth scope Function Block (FB) terms,
* per &sect;REQ.21.4.2.1.3.1 (ScopeFBTerms).
*
* <p>This enum is the single in-code home for ESPI FB semantics. Each constant carries the
* FB's numeric id (the wire value used in the {@code FB=} scope term), its
* {@link FunctionBlockCategory}, the spec title, and &mdash; for most {@code COMMODITY} FBs &mdash;
* the {@link ServiceKind} it selects.</p>
*
* <p>Only <em>active</em> (non-deprecated) FBs are enumerated. FB ids the spec marks
* {@code [DEPRECATED]} are recorded in {@link #DEPRECATED_IDS} so that {@link #categoryOf(int)}
* reports {@link FunctionBlockCategory#DEPRECATED} rather than {@link FunctionBlockCategory#UNKNOWN}.
* Any id absent from both is genuinely outside the spec table and resolves to {@code UNKNOWN}.</p>
*
* <p>The {@link EspiScope} parser is intentionally FB-agnostic: it carries whatever FB ids appear
* in a scope string, so future or unrecognized FBs still parse cleanly. This catalog is consulted
* only when a category or service kind is needed. The {@code title} is diagnostic only (logging /
* documentation) &mdash; it is never serialized and never drives an authorization decision.</p>
*
* @see FunctionBlockCategory
* @see EspiScope
*/
public enum FunctionBlock {

// --- Energy Usage domain -------------------------------------------------
FB_01(1, FunctionBlockCategory.PLATFORM, "Common (Energy Usage)"),
FB_02(2, FunctionBlockCategory.INTERACTION, "Download My Data (Energy Usage)"),
FB_03(3, FunctionBlockCategory.INTERACTION, "Connect My Data (Energy Usage)"),
FB_04(4, FunctionBlockCategory.BASE, "Interval Metering"),
FB_05(5, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Interval Electricity Metering"),
FB_06(6, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Demand Electricity Metering"),
FB_07(7, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Net Metering"),
FB_08(8, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Forward and Reverse Metering"),
FB_09(9, FunctionBlockCategory.COMMODITY, ServiceKind.ELECTRICITY, "Register Values"),
FB_10(10, FunctionBlockCategory.COMMODITY, ServiceKind.GAS, "Gas Interval Metering"),
FB_11(11, FunctionBlockCategory.COMMODITY, ServiceKind.WATER, "Water Interval Metering"),
FB_12(12, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Cost of Interval Data"),
FB_13(13, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Energy Usage)"),
FB_15(15, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary"),
FB_16(16, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Cost"),
FB_17(17, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Power Quality Summary"),
FB_27(27, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary with Demands and Previous Day Attributes"),
FB_28(28, FunctionBlockCategory.ENERGY_DATA_SHAPE, "Usage Summary Costs for Current Billing Period"),

/**
* Temperature Interval Metering &mdash; commodity FB with <em>no</em> {@link ServiceKind} mapping.
*
* <p>The ESPI 4.0 {@code ServiceKind} XSD enumeration (electricity, gas, water, time, heat,
* refuse, sewerage, rates, TV licence, internet) has no value for temperature, so a UsagePoint's
* {@code ServiceCategory.kind} cannot encode it &mdash; a known gap in the standard. FB 29 is
* therefore {@link FunctionBlockCategory#COMMODITY} with no service kind, and
* {@link EspiScope#commodityServiceKinds()} will not include any kind solely on its account.</p>
*/
FB_29(29, FunctionBlockCategory.COMMODITY, "Temperature Interval Metering"),

FB_30(30, FunctionBlockCategory.PLATFORM, "Common User Experience"),
FB_31(31, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope"),
FB_35(35, FunctionBlockCategory.BULK_TRANSFER, "REST for Energy Usage Bulk"),
FB_37(37, FunctionBlockCategory.INTERACTION, "Query Parameters (Energy Usage)"),
FB_39(39, FunctionBlockCategory.INTERACTION, "PUSH Model (Energy Usage)"),
FB_40(40, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Energy Usage)"),
FB_41(41, FunctionBlockCategory.ADMINISTRATION, "Manage ApplicationInformation Resource"),
FB_44(44, FunctionBlockCategory.ADMINISTRATION, "Manage Authorization Resource"),

// --- Retail Customer domain ----------------------------------------------
FB_51(51, FunctionBlockCategory.PLATFORM, "Common (Retail Customer)"),
FB_52(52, FunctionBlockCategory.INTERACTION, "Download My Data (Retail Customer)"),
FB_53(53, FunctionBlockCategory.BASE, "Connect My Data (Retail Customer)"),
FB_54(54, FunctionBlockCategory.CUSTOMER_PII, "Basic Retail Customer Information"),
FB_55(55, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Address Information"),
FB_56(56, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Billing Information"),
FB_57(57, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer AccountAgreement Information"),
FB_58(58, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceLocation Information"),
FB_59(59, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ServiceSupplier Information"),
FB_60(60, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer Meter Information"),
FB_61(61, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer EndDevice Information"),
FB_62(62, FunctionBlockCategory.CUSTOMER_PII, "Retail Customer ProgramDateIdMapping Information"),
FB_63(63, FunctionBlockCategory.PLATFORM, "Common User Experience (Retail Customer)"),
FB_64(64, FunctionBlockCategory.PLATFORM, "Security and Privacy Classes (Retail Customer)"),
FB_65(65, FunctionBlockCategory.AUTHORIZATION, "Authorization and Authentication w/o Pre-Negotiated Scope (Retail Customer)"),
FB_67(67, FunctionBlockCategory.BULK_TRANSFER, "REST for Retail Customer Bulk"),
FB_68(68, FunctionBlockCategory.INTERACTION, "Query Parameters (Retail Customer)"),
FB_69(69, FunctionBlockCategory.INTERACTION, "PUSH Model (Retail Customer)"),
FB_70(70, FunctionBlockCategory.AUTHORIZATION, "Offline Authorization (Retail Customer)");

/**
* FB ids that NAESB ESPI 4.0 marks {@code [DEPRECATED]} (no longer assignable but reserved so
* the numbers are not reused). Recorded so {@link #categoryOf(int)} can distinguish a deprecated
* FB from one that was never defined. Includes the SFTP bulk FBs (34, 66), which the standard
* retired in favor of the REST bulk FBs (35, 67).
*/
public static final Set<Integer> DEPRECATED_IDS =
Set.of(14, 18, 19, 32, 33, 34, 36, 38, 46, 47, 48, 49, 50, 66);

private static final Map<Integer, FunctionBlock> BY_ID =
Stream.of(values()).collect(Collectors.toUnmodifiableMap(FunctionBlock::getId, Function.identity()));

private final int id;
private final FunctionBlockCategory category;
private final ServiceKind serviceKind;
private final String title;

FunctionBlock(int id, FunctionBlockCategory category, String title) {
this(id, category, null, title);
}

FunctionBlock(int id, FunctionBlockCategory category, ServiceKind serviceKind, String title) {
this.id = id;
this.category = category;
this.serviceKind = serviceKind;
this.title = title;
}

/** @return the FB's numeric id (the value used in the {@code FB=} scope term). */
public int getId() {
return id;
}

/** @return the functional category this FB belongs to. */
public FunctionBlockCategory getCategory() {
return category;
}

/**
* @return the {@link ServiceKind} selected by this commodity FB, or empty for non-commodity FBs
* and for commodity FBs with no XSD-defined kind (FB 29 / Temperature)
*/
public Optional<ServiceKind> getServiceKind() {
return Optional.ofNullable(serviceKind);
}

/** @return the NAESB spec title for this FB (diagnostic only). */
public String getTitle() {
return title;
}

/**
* Look up an active FB by its numeric id.
*
* @param id the FB number
* @return the catalogued FB, or empty if the id is deprecated or undefined
*/
public static Optional<FunctionBlock> byId(int id) {
return Optional.ofNullable(BY_ID.get(id));
}

/**
* Categorize an FB id, tolerating ids that are not catalogued.
*
* @param id the FB number
* @return the active FB's category; {@link FunctionBlockCategory#DEPRECATED} for a
* spec-deprecated id; {@link FunctionBlockCategory#UNKNOWN} otherwise
*/
public static FunctionBlockCategory categoryOf(int id) {
FunctionBlock fb = BY_ID.get(id);
if (fb != null) {
return fb.category;
}
return DEPRECATED_IDS.contains(id) ? FunctionBlockCategory.DEPRECATED : FunctionBlockCategory.UNKNOWN;
}

/**
* Resolve the {@link ServiceKind} a commodity FB id selects.
*
* @param id the FB number
* @return the service kind for a commodity FB, or empty for any other (or undefined) id
*/
public static Optional<ServiceKind> serviceKindOf(int id) {
FunctionBlock fb = BY_ID.get(id);
return fb == null ? Optional.empty() : fb.getServiceKind();
}
}
Loading
Loading