diff --git a/README.md b/README.md
index e1914fa754a553a44b2622de139379520a944381..d6189af1a9b672d9beb366d275d224d0ea8b9b43 100644
--- a/README.md
+++ b/README.md
@@ -150,7 +150,6 @@ Next change the version in the following places:
    through [https://git.astron.nl/lofar2.0/tango/-/tags](Deploy Tags)
 
 # Release Notes
-* 0.47.1 Move GrafanaAPIV3 RPC interface to Opah repo
 * 0.47.0 Migrate from lofar-station-client to lofar-lotus package. Update various package dependencies.
 * 0.46.1 Include clock_RW in metadata JSON
 * 0.46.0 Expose latest BST/SST/XST from ZMQ over gRPC
diff --git a/tangostationcontrol/VERSION b/tangostationcontrol/VERSION
index 46e33ab94c3e95d77ab81dee1693be27bde0a2d4..421ab545d9adc503fddf2ea2ce447a9a8c5323af 100644
--- a/tangostationcontrol/VERSION
+++ b/tangostationcontrol/VERSION
@@ -1 +1 @@
-0.47.1
\ No newline at end of file
+0.47.0
diff --git a/tangostationcontrol/proto/README.md b/tangostationcontrol/proto/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..42f5d292ac169bd47ab64000d8c355c3bc96a3f4
--- /dev/null
+++ b/tangostationcontrol/proto/README.md
@@ -0,0 +1,3 @@
+Protobuf definitions for the RPC services in the tangostationcontrol.rpc module.
+
+`grafana-apiv3.proto` is taken from https://raw.githubusercontent.com/innius/grafana-simple-grpc-datasource/master/pkg/proto/v3/apiv3.proto
diff --git a/tangostationcontrol/proto/antennafield.proto b/tangostationcontrol/proto/antennafield.proto
new file mode 100644
index 0000000000000000000000000000000000000000..c4f92628e48c51812dbe2d968612576903015228
--- /dev/null
+++ b/tangostationcontrol/proto/antennafield.proto
@@ -0,0 +1,57 @@
+syntax = "proto3";
+
+service Antennafield {
+  rpc GetAntenna(GetAntennaRequest) returns (AntennaReply) {}
+  rpc SetAntennaStatus(SetAntennaStatusRequest) returns (AntennaReply) {}
+  rpc SetAntennaUse(SetAntennaUseRequest) returns (AntennaReply) {}
+}
+
+enum Antenna_Status {
+  OK = 0;
+  SUSPICIOUS = 1;
+  BROKEN = 2;
+  BEYOND_REPAIR = 3;
+  NOT_AVAILABLE = 4;
+}
+
+enum Antenna_Use {
+  // use antenna only if it's OK or SUSPICIOUS
+  AUTO = 0;
+  // force antenna to be on, regardless of status
+  ON = 1;
+  // force antenna to be off, regardless of status
+  OFF = 2;
+}
+
+message Identifier {
+  // e.g. "LBA"
+  string antennafield_name = 1;
+  // e.g. "LBA00"
+  string antenna_name = 2;
+}
+
+message SetAntennaStatusRequest {
+  Identifier identifier = 1;
+  Antenna_Status antenna_status = 2 ;
+}
+
+message GetAntennaRequest {
+  Identifier identifier = 1;
+}
+
+message SetAntennaUseRequest {
+  Identifier identifier = 1;
+  Antenna_Use antenna_use = 2;
+}
+
+message AntennaResult {
+  Identifier identifier = 1;
+  Antenna_Use antenna_use = 2;
+  Antenna_Status antenna_status = 3;
+}
+
+message AntennaReply {
+  bool success = 1;
+  string exception = 2;
+  AntennaResult result = 3;
+}
diff --git a/tangostationcontrol/proto/grafana-apiv3.proto b/tangostationcontrol/proto/grafana-apiv3.proto
new file mode 100644
index 0000000000000000000000000000000000000000..01a960b09e990cee7881c7a5f22576745f9f75fb
--- /dev/null
+++ b/tangostationcontrol/proto/grafana-apiv3.proto
@@ -0,0 +1,360 @@
+syntax = "proto3";
+
+option go_package = "bitbucket.org/innius/grafana-simple-grpc-datasource/v3";
+
+import "google/protobuf/timestamp.proto";
+
+package grafanav3;
+
+// The GrafanaQueryAPI definition.
+service GrafanaQueryAPI {
+  // Returns a list of all available dimensions
+  rpc ListDimensionKeys (ListDimensionKeysRequest) returns (ListDimensionKeysResponse) {
+  }
+
+  // Returns a list of all dimension values for a certain dimension
+  rpc ListDimensionValues (ListDimensionValuesRequest) returns (ListDimensionValuesResponse) {
+  }
+
+  // Returns all metrics from the system
+  rpc ListMetrics (ListMetricsRequest) returns (ListMetricsResponse) {
+  }
+
+  // Gets the options for the specified query type
+  rpc GetQueryOptions (GetOptionsRequest) returns (GetOptionsResponse) {
+
+  }
+
+  // Gets the last known value for one or more metrics
+  rpc GetMetricValue (GetMetricValueRequest) returns (GetMetricValueResponse) {
+  }
+
+  // Gets the history for one or more metrics
+  rpc GetMetricHistory (GetMetricHistoryRequest) returns (GetMetricHistoryResponse) {
+  }
+
+  // Gets the history for one or more metrics
+  rpc GetMetricAggregate(GetMetricAggregateRequest) returns (GetMetricAggregateResponse) {
+  }
+}
+
+message ListMetricsRequest {
+  repeated Dimension dimensions = 1;
+  string filter = 2;
+}
+
+message ListMetricsResponse {
+  message Metric {
+    string name = 1;
+    string description = 2;
+  }
+  repeated Metric Metrics = 1;
+}
+
+message GetMetricValueRequest {
+  repeated Dimension dimensions = 1;
+
+  repeated string metrics = 2;
+
+  map<string,string> options = 3 ;
+
+  google.protobuf.Timestamp startDate = 4;
+  google.protobuf.Timestamp endDate = 5;
+}
+
+message GetMetricValueResponse {
+  message Frame {
+    string metric = 1;
+
+    google.protobuf.Timestamp timestamp = 2;
+
+    repeated SingleValueField fields = 3;
+
+    FrameMeta meta = 4;
+  }
+
+  repeated Frame frames = 1;
+}
+
+message GetOptionsRequest {
+  enum QueryType {
+    GetMetricHistory = 0;
+    GetMetricValue=1;
+    GetMetricAggregate=2;
+  }
+  // the query type for which options are requested
+  QueryType queryType = 1;
+
+  // the query options which are currently selected 
+  map<string,string> selectedOptions = 2 ;
+}
+
+message EnumValue {
+  // the id of the enum value 
+  string id = 1;
+  // the description of the option
+  string description = 2;
+  // the label of the option 
+  string label = 3;
+  // the default enum value
+  bool default = 4;
+}
+
+message Option {
+  // the id of the option 
+  string id = 1; 
+  string description = 2;
+  enum Type {
+    Enum = 0; // enum is rendered as a Select control in the frontend 
+    Boolean = 1;
+  }
+  Type  type = 3; 
+  repeated EnumValue enumValues = 4;
+  bool required = 5; 
+  // the label of the option 
+  string label = 6;
+}
+
+message GetOptionsResponse {
+  repeated Option options = 1;
+}
+
+message GetMetricAggregateRequest {
+  // The dimensions for the query
+  repeated Dimension dimensions = 1;
+
+  // the metrics for which the aggregates are retrieved
+  repeated string metrics = 2;
+
+  google.protobuf.Timestamp startDate = 4;
+  google.protobuf.Timestamp endDate = 5;
+  int64 maxItems = 6;
+  TimeOrdering timeOrdering = 7;
+  string startingToken = 8;
+  int64 intervalMs = 9;
+  map<string,string> options = 10 ;
+}
+
+message GetMetricAggregateResponse {
+  repeated Frame frames = 1;
+
+  string nextToken = 2;
+}
+
+message GetMetricHistoryRequest {
+  repeated Dimension dimensions = 3;
+  repeated string metrics = 4;
+  google.protobuf.Timestamp startDate = 5;
+  google.protobuf.Timestamp endDate = 6;
+  int64 maxItems = 7;
+  TimeOrdering timeOrdering = 8;
+  string startingToken = 9;
+  map<string,string> options = 10 ;
+}
+
+message GetMetricHistoryResponse {
+  repeated Frame frames = 1;
+
+  string nextToken = 2;
+}
+
+message Label {
+  string key = 1;
+  string value = 2;
+}
+
+message Field {
+  string name = 1;
+
+  repeated Label labels = 2;
+
+  config config = 3;
+
+  repeated double values = 4;
+  repeated string stringValues = 5;
+}
+
+message ValueMapping {
+  double from = 1;
+  double to = 2; 
+  string value = 3; 
+  string text = 4;
+  string color = 5;
+}
+
+message config {
+  string unit = 1;
+
+  repeated ValueMapping Mappings = 2;
+}
+
+message SingleValueField {
+  string name = 1;
+
+  repeated Label labels = 2;
+
+  config config = 3;
+
+  double value = 4;
+
+  string stringValue = 5;
+}
+
+// The data frame for each metric
+message Frame {
+  string metric = 1;
+
+  repeated google.protobuf.Timestamp timestamps = 2;
+
+  repeated Field fields = 3;
+
+  FrameMeta meta = 4;
+}
+
+// FrameMeta matches:
+// https://github.com/grafana/grafana/blob/master/packages/grafana-data/src/types/data.ts#L11
+// NOTE -- in javascript this can accept any `[key: string]: any;` however
+// this interface only exposes the values we want to be exposed
+message FrameMeta {
+  enum FrameType {
+    FrameTypeUnknown = 0;
+    FrameTypeTimeSeriesWide = 1;
+    FrameTypeTimeSeriesLong = 2;
+    FrameTypeTimeSeriesMany = 3;
+    FrameTypeDirectoryListing = 4;
+    FrameTypeTable = 5;
+  }
+  // Type asserts that the frame matches a known type structure
+  FrameType type = 1 ;
+
+  message Notice {
+    enum NoticeSeverity {
+      // NoticeSeverityInfo is informational severity.
+      NoticeSeverityInfo = 0;
+      // NoticeSeverityWarning is warning severity.
+      NoticeSeverityWarning = 1;
+      // NoticeSeverityError is error severity.
+      NoticeSeverityError = 3;
+    }
+    // Severity is the severity level of the notice: info, warning, or error.
+    NoticeSeverity Severity = 1;
+
+    // Text is freeform descriptive text for the notice.
+    string text = 2;
+
+    // Link is an optional link for display in the user interface and can be an
+    // absolute URL or a path relative to Grafana's root url.
+    string link = 3;
+
+    enum InspectType {
+      // InspectTypeNone is no suggestion for a tab of the panel editor in Grafana's user interface.
+      InspectTypeNone = 0;
+
+      // InspectTypeMeta suggests the "meta" tab of the panel editor in Grafana's user interface.
+      InspectTypeMeta = 1;
+
+      // InspectTypeError suggests the "error" tab of the panel editor in Grafana's user interface.
+      InspectTypeError = 2;
+
+      // InspectTypeData suggests the "data" tab of the panel editor in Grafana's user interface.
+      InspectTypeData = 3;
+
+      // InspectTypeStats suggests the "stats" tab of the panel editor in Grafana's user interface.
+      InspectTypeStats = 4;
+    }
+    // Inspect is an optional suggestion for which tab to display in the panel inspector
+    // in Grafana's User interface. Can be meta, error, data, or stats.
+    InspectType inspect = 4;
+  }
+  // Notices provide additional information about the data in the Frame that
+  // Grafana can display to the user in the user interface.
+  repeated Notice  Notices = 6;
+
+  // VisType is used to indicate how the data should be visualized in explore.
+  enum VisType {
+    // VisTypeGraph indicates the response should be visualized using a graph.
+    VisTypeGraph = 0;
+
+    // VisTypeTable indicates the response should be visualized using a table.
+    VisTypeTable = 1;
+
+    // VisTypeLogs indicates the response should be visualized using a logs visualization.
+    VisTypeLogs = 2;
+
+    // VisTypeTrace indicates the response should be visualized using a trace view visualization.
+    VisTypeTrace = 3;
+
+    // VisTypeNodeGraph indicates the response should be visualized using a node graph visualization.
+    VisTypeNodeGraph = 4;
+  }
+
+  // PreferredVisualization is currently used to show results in Explore only in preferred visualisation option.
+  VisType PreferredVisualization = 8;
+
+  // ExecutedQueryString is the raw query sent to the underlying system. All macros and templating
+  // have been applied.  When metadata contains this value, it will be shown in the query inspector.
+  string executedQueryString = 9;
+}
+
+enum TimeOrdering {
+  ASCENDING = 0;
+  DESCENDING = 1;
+}
+
+message ListDimensionKeysRequest {
+  string filter = 1;
+  repeated Dimension selected_dimensions = 2;
+}
+
+message ListDimensionKeysResponse {
+  message Result {
+    string key = 1;
+    string description = 2;
+  }
+  repeated Result results = 1;
+}
+
+message ListDimensionValuesRequest {
+  string dimension_key = 1;
+  string filter = 2;
+  repeated Dimension selected_dimensions = 3;
+}
+
+message ListDimensionValuesResponse {
+  message Result {
+    string value = 1;
+    string description = 2;
+  }
+  repeated Result results = 1;
+}
+
+message TimeRange {
+  int64 fromEpochMS = 1;
+  int64 toEpochMS = 2;
+}
+
+message Dimension {
+  string key = 1;
+  string value = 2;
+}
+
+message QueryRequest {
+  string refId = 1;
+  int64 maxDataPoints = 2;
+  int64 intervalMS = 3;
+  TimeRange timeRange = 4;
+  // The offset for the result set
+  int64 startKey = 5;
+  repeated Dimension dimensions = 6;
+}
+
+// The response message containing the greetings
+message QueryResponse {
+  string refId = 1;
+  int64 nextKey = 2;
+  message Value {
+    int64 timestamp = 1;
+    float value = 2;
+  }
+  repeated Value values = 3;
+}
diff --git a/tangostationcontrol/proto/observation.proto b/tangostationcontrol/proto/observation.proto
new file mode 100644
index 0000000000000000000000000000000000000000..3a08fe61c83d546fcda1012a641227ff7246e00d
--- /dev/null
+++ b/tangostationcontrol/proto/observation.proto
@@ -0,0 +1,18 @@
+syntax = "proto3";
+
+service Observation {
+  rpc StartObservation(StartObservationRequest) returns (ObservationReply){}
+  rpc StopObservation(StopObservationRequest) returns (ObservationReply) {}
+}
+
+message StartObservationRequest {
+  string configuration = 1;
+}
+message StopObservationRequest {
+  int64 observation_id = 1;
+}
+
+message ObservationReply {
+  bool success = 1;
+  string exception = 2;
+}
diff --git a/tangostationcontrol/proto/statistics.proto b/tangostationcontrol/proto/statistics.proto
new file mode 100644
index 0000000000000000000000000000000000000000..67d55f30ee17d7a5342765635a324ac425e84144
--- /dev/null
+++ b/tangostationcontrol/proto/statistics.proto
@@ -0,0 +1,101 @@
+syntax = "proto3";
+
+import "google/protobuf/timestamp.proto";
+
+service Statistics {
+  rpc Bst(BstRequest) returns (BstReply) {}
+  rpc Sst(SstRequest) returns (SstReply) {}
+  rpc Xst(XstRequest) returns (XstReply) {}
+}
+
+message FrequencyBand {
+  string antenna_type = 1;
+  int32 clock = 2;
+  int32 nyquist_zone = 3;
+}
+
+message BstRequest {
+  string antenna_field = 1;
+  optional uint32 maxage = 2;
+}
+
+message BstResult {
+  google.protobuf.Timestamp timestamp = 1;
+  FrequencyBand frequency_band = 2;
+  float integration_interval = 3;
+
+  message BstBeamlet {
+    int32 beamlet = 1;
+    float x_power_db = 2;
+    float y_power_db = 3;
+  }
+
+  repeated BstBeamlet beamlets = 4;
+}
+
+message BstReply {
+  BstResult result = 3;
+}
+
+
+message SstRequest {
+  string antenna_field = 1;
+  optional uint32 maxage = 2;
+}
+
+message SstResult {
+  google.protobuf.Timestamp timestamp = 1;
+  FrequencyBand frequency_band = 2;
+  float integration_interval = 3;
+
+  message SstSubband {
+    int32 subband = 1;
+
+    message SstAntenna {
+      int32 antenna = 1;
+      float x_power_db = 2;
+      float y_power_db = 3;
+    }
+
+    repeated SstAntenna antennas = 2;
+  }
+
+  repeated SstSubband subbands = 4;
+}
+
+message SstReply {
+  SstResult result = 3;
+}
+
+message XstRequest {
+  string antenna_field = 1;
+  optional uint32 maxage = 2;
+}
+
+message XstResult {
+  google.protobuf.Timestamp timestamp = 1;
+  FrequencyBand frequency_band = 2;
+  float integration_interval = 3;
+  int32 subband = 4;
+
+  message XstBaseline {
+    int32 antenna1 = 1;
+    int32 antenna2 = 2;
+
+    message XstValue {
+      float power_db = 1;
+      float phase = 2;
+    }
+
+    XstValue xx = 3;
+    XstValue xy = 4;
+    XstValue yx = 5;
+    XstValue yy = 6;
+  }
+
+  repeated XstBaseline baselines = 5;
+}
+
+message XstReply {
+  XstResult result = 3;
+}
diff --git a/tangostationcontrol/tangostationcontrol/rpc/antennafield.py b/tangostationcontrol/tangostationcontrol/rpc/antennafield.py
index a5a88e7dbba100afee30e12566c7446b2d6dd62a..b90731ae88e32b99d87aff32a721ce9e2132bc89 100644
--- a/tangostationcontrol/tangostationcontrol/rpc/antennafield.py
+++ b/tangostationcontrol/tangostationcontrol/rpc/antennafield.py
@@ -6,9 +6,9 @@ import tango
 from tango import DeviceProxy
 
 from tangostationcontrol.common.antennas import antenna_field_family_name
-from lofar_sid.interface.stationcontrol import antennafield_pb2_grpc
+from tangostationcontrol.rpc._proto import antennafield_pb2_grpc
 
-from lofar_sid.interface.stationcontrol.antennafield_pb2 import (
+from tangostationcontrol.rpc._proto.antennafield_pb2 import (
     AntennaReply,
     AntennaResult,
     GetAntennaRequest,
diff --git a/tangostationcontrol/tangostationcontrol/rpc/grafana_api.py b/tangostationcontrol/tangostationcontrol/rpc/grafana_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..b3e66eec6ca6e5eeaaa82b45aca9cc7127e16f58
--- /dev/null
+++ b/tangostationcontrol/tangostationcontrol/rpc/grafana_api.py
@@ -0,0 +1,525 @@
+# Copyright (C) 2025 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: Apache-2.0
+
+"""Exposure of the station's statistics for the innius-rpc-datasource plugin in Grafana."""
+
+from datetime import datetime, timezone
+import itertools
+import math
+from typing import Callable
+
+from tangostationcontrol.common.frequency_bands import Band, bands
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2_grpc
+from tangostationcontrol.rpc._proto import statistics_pb2
+from tangostationcontrol.rpc.statistics import (
+    Statistics,
+    TooOldError,
+)
+from tangostationcontrol.rpc.common import (
+    call_exception_metrics,
+)
+
+
+class StatisticsToGrafana:
+    """Abstract base class for converting Statistics responses to Grafana Frames."""
+
+    # Meta information for our statistics
+    meta = grafana_apiv3_pb2.FrameMeta(
+        type=grafana_apiv3_pb2.FrameMeta.FrameType.FrameTypeTimeSeriesLong,
+        PreferredVisualization=grafana_apiv3_pb2.FrameMeta.VisType.VisTypeGraph,
+    )
+
+    def __init__(self, statistics: Statistics):
+        super().__init__()
+
+        self.statistics = statistics
+
+    @staticmethod
+    def _subband_frequency_func(
+        frequency_band: statistics_pb2.FrequencyBand,
+    ) -> Callable[[int], float]:
+        """Return a function that converts a subband number into its central frequency (in Hz).
+
+        NB: Spectral inversion is assumed to have been configured correctly at time of recording.
+        """
+
+        band_name = Band.lookup_nyquist_zone(
+            frequency_band.antenna_type,
+            frequency_band.clock,
+            frequency_band.nyquist_zone,
+        )
+
+        return bands[band_name].subband_frequency
+
+    @staticmethod
+    def _verify_call_result(reply, time_window: tuple[datetime, datetime]):
+        """Verify reply from Statistics service. Raises if verification fails."""
+
+        if not (
+            time_window[0]
+            <= reply.result.timestamp.ToDatetime(tzinfo=timezone.utc)
+            < time_window[1]
+        ):
+            raise TooOldError(
+                f"Stastistics not available in time window {time_window}. Available is {reply.result.timestamp}."
+            )
+
+        return True
+
+
+class BstToGrafana(StatisticsToGrafana):
+    """Converts Statistics.BST responses to Grafana Frames."""
+
+    def _get_latest_in_window(
+        self, time_window: tuple[datetime, datetime], antenna_field: str
+    ) -> statistics_pb2.BstReply:
+        """Get the latest statistics in the given time window, if any."""
+
+        request = statistics_pb2.BstRequest(
+            antenna_field=antenna_field,
+        )
+        reply = self.statistics.Bst(request, None)
+
+        self._verify_call_result(reply, time_window)
+
+        return reply
+
+    @call_exception_metrics("StatisticsToGrafana", {"type": "bst"})
+    def all_frames(
+        self,
+        time_window: tuple[datetime, datetime],
+        antenna_field: str,
+        pol: str | None,
+    ) -> list[grafana_apiv3_pb2.Frame]:
+        """Return all Grafana Frames for the requested data."""
+
+        try:
+            reply = self._get_latest_in_window(time_window, antenna_field)
+            result = reply.result
+        except TooOldError:
+            return []
+
+        # Turn result into Grafana fields
+        #
+        # Each value describes the power for one beamlet.
+        #
+        # Each polarisation results in one field.
+        frames = [
+            grafana_apiv3_pb2.Frame(
+                metric="BST",
+                timestamps=[result.timestamp for _ in result.beamlets],
+                fields=list(
+                    filter(
+                        None,
+                        [
+                            grafana_apiv3_pb2.Field(
+                                name="beamlet",
+                                values=[b.beamlet for b in result.beamlets],
+                            ),
+                            (
+                                grafana_apiv3_pb2.Field(
+                                    name="xx",
+                                    config=grafana_apiv3_pb2.config(
+                                        unit="dB",
+                                    ),
+                                    values=[b.x_power_db for b in result.beamlets],
+                                )
+                                if pol in ["xx", None]
+                                else None
+                            ),
+                            (
+                                grafana_apiv3_pb2.Field(
+                                    name="yy",
+                                    config=grafana_apiv3_pb2.config(
+                                        unit="dB",
+                                    ),
+                                    values=[b.y_power_db for b in result.beamlets],
+                                )
+                                if pol in ["yy", None]
+                                else None
+                            ),
+                        ],
+                    )
+                ),
+                meta=self.meta,
+            )
+        ]
+
+        return frames
+
+
+class SstToGrafana(StatisticsToGrafana):
+    """Converts Statistics.SST responses to Grafana Frames."""
+
+    def _get_latest_in_window(
+        self, time_window: tuple[datetime, datetime], antenna_field: str
+    ) -> statistics_pb2.SstReply:
+        """Get the latest statistics in the given time window, if any."""
+
+        request = statistics_pb2.SstRequest(
+            antenna_field=antenna_field,
+        )
+        reply = self.statistics.Sst(request, None)
+
+        self._verify_call_result(reply, time_window)
+
+        return reply
+
+    @call_exception_metrics("StatisticsToGrafana", {"type": "sst"})
+    def all_frames(
+        self,
+        time_window: tuple[datetime, datetime],
+        antenna_field: str,
+        selected_pol: str | None,
+    ) -> list[grafana_apiv3_pb2.Frame]:
+        """Return all Grafana Frames for the requested data."""
+
+        try:
+            reply = self._get_latest_in_window(time_window, antenna_field)
+            result = reply.result
+        except TooOldError:
+            return []
+
+        # Turn result into Grafana fields
+        #
+        # Each value describes the power of an antenna for a specific subband.
+        #
+        # Field 0 are the spectral frequencies for each value.
+        # Field 1+ is one field for each antenna and each requested polarisation.
+        antenna_nrs = [antenna.antenna for antenna in result.subbands[0].antennas]
+        fields_per_antenna = [
+            [
+                (
+                    grafana_apiv3_pb2.Field(
+                        name="power",
+                        labels=[
+                            grafana_apiv3_pb2.Label(
+                                key="antenna",
+                                value="%03d" % antenna_nr,
+                            ),
+                            grafana_apiv3_pb2.Label(
+                                key="pol",
+                                value="xx",
+                            ),
+                        ],
+                        config=grafana_apiv3_pb2.config(
+                            unit="dB",
+                        ),
+                        values=[
+                            subband.antennas[antenna_nr].x_power_db
+                            for subband in result.subbands
+                        ],
+                    )
+                    if selected_pol in ["xx", None]
+                    else None
+                ),
+                (
+                    grafana_apiv3_pb2.Field(
+                        name="power",
+                        labels=[
+                            grafana_apiv3_pb2.Label(
+                                key="antenna",
+                                value="%03d" % antenna_nr,
+                            ),
+                            grafana_apiv3_pb2.Label(
+                                key="pol",
+                                value="yy",
+                            ),
+                        ],
+                        config=grafana_apiv3_pb2.config(
+                            unit="dB",
+                        ),
+                        values=[
+                            subband.antennas[antenna_nr].y_power_db
+                            for subband in result.subbands
+                        ],
+                    )
+                    if selected_pol in ["yy", None]
+                    else None
+                ),
+            ]
+            for antenna_nr in antenna_nrs
+        ]
+
+        subband_frequency = self._subband_frequency_func(result.frequency_band)
+
+        frames = [
+            grafana_apiv3_pb2.Frame(
+                metric="SST",
+                timestamps=[result.timestamp for _ in result.subbands],
+                fields=[
+                    grafana_apiv3_pb2.Field(
+                        name="frequency",
+                        config=grafana_apiv3_pb2.config(
+                            unit="Hz",
+                        ),
+                        values=[subband_frequency(b.subband) for b in result.subbands],
+                    ),
+                ]
+                + list(filter(None, itertools.chain(*fields_per_antenna))),
+                meta=self.meta,
+            )
+        ]
+
+        return frames
+
+
+class XstToGrafana(StatisticsToGrafana):
+    """Converts Statistics.XST responses to Grafana Frames."""
+
+    def _get_latest_in_window(
+        self, time_window: tuple[datetime, datetime], antenna_field: str
+    ) -> statistics_pb2.XstReply:
+        """Get the latest statistics in the given time window, if any."""
+
+        request = statistics_pb2.XstRequest(
+            antenna_field=antenna_field,
+        )
+        reply = self.statistics.Xst(request, None)
+
+        self._verify_call_result(reply, time_window)
+
+        return reply
+
+    @call_exception_metrics("StatisticsToGrafana", {"type": "xst"})
+    def all_frames(
+        self,
+        time_window: tuple[datetime, datetime],
+        antenna_field: str,
+        selected_pol: str | None,
+    ) -> list[grafana_apiv3_pb2.Frame]:
+        """Return all Grafana Frames for the requested data."""
+
+        try:
+            reply = self._get_latest_in_window(time_window, antenna_field)
+            result = reply.result
+        except TooOldError:
+            return []
+
+        subband_frequency = self._subband_frequency_func(result.frequency_band)
+
+        # Turn result into Grafana fields
+        #
+        # Each value describes a baseline.
+        #
+        # Field 0 & 1 are the (antenna1, antenna2) indices describing each baseline.
+        # Field 2 is the central frequency of the values for each baseline.
+        fields = [
+            grafana_apiv3_pb2.Field(
+                name="antenna1",
+                values=[baseline.antenna1 for baseline in result.baselines],
+            ),
+            grafana_apiv3_pb2.Field(
+                name="antenna2",
+                values=[baseline.antenna2 for baseline in result.baselines],
+            ),
+            grafana_apiv3_pb2.Field(
+                name="frequency",
+                config=grafana_apiv3_pb2.config(
+                    unit="Hz",
+                ),
+                values=[subband_frequency(result.subband) for _ in result.baselines],
+            ),
+        ]
+
+        # Subsequent fields describe the power and phase for each baseline, and
+        # the requested, or all, polarisations.
+        for pol in ("xx", "xy", "yx", "yy"):
+            if selected_pol is None or pol == selected_pol:
+                labels = (
+                    [
+                        grafana_apiv3_pb2.Label(
+                            key="pol",
+                            value=pol,
+                        )
+                    ]
+                    if not selected_pol
+                    else []
+                )
+
+                fields.extend(
+                    [
+                        grafana_apiv3_pb2.Field(
+                            name="power",
+                            labels=labels,
+                            config=grafana_apiv3_pb2.config(
+                                unit="dB",
+                            ),
+                            values=[
+                                getattr(baseline, pol).power_db
+                                for baseline in result.baselines
+                            ],
+                        ),
+                        grafana_apiv3_pb2.Field(
+                            name="phase",
+                            labels=labels,
+                            config=grafana_apiv3_pb2.config(
+                                unit="deg",
+                            ),
+                            values=[
+                                math.fabs(
+                                    getattr(baseline, pol).phase * 360.0 / math.pi
+                                )
+                                for baseline in result.baselines
+                            ],
+                        ),
+                    ]
+                )
+
+        frames = [
+            grafana_apiv3_pb2.Frame(
+                metric="XST",
+                timestamps=[result.timestamp for _ in result.baselines],
+                fields=fields,
+                meta=self.meta,
+            )
+        ]
+
+        return frames
+
+
+class GrafanaAPIV3(grafana_apiv3_pb2_grpc.GrafanaQueryAPIServicer):
+    """Implements the Grafana interface for the innius simple-rpc-datasource,
+    see https://github.com/innius/grafana-simple-grpc-datasource"""
+
+    def __init__(self, statistics: Statistics, antenna_fields: list[str]):
+        super().__init__()
+
+        self.statistics = statistics
+        self.antenna_fields = antenna_fields
+        self.bst = BstToGrafana(self.statistics)
+        self.sst = SstToGrafana(self.statistics)
+        self.xst = XstToGrafana(self.statistics)
+
+        # self._import_test_data()
+
+    def _import_test_data(self):
+        """Import test data for demo purposes."""
+
+        import json
+        import os
+
+        test_dir = os.path.dirname(__file__) + "/../../test/rpc/"
+
+        for type_ in ("bst", "sst", "xst"):
+            with open(test_dir + f"{type_}-message.json") as f:
+                print(f"Loading {f}")
+                message = json.load(f)
+
+                self.statistics.handle_statistics_message(
+                    f"{type_}/lba/cs032", datetime.now(tz=timezone.utc), message
+                )
+
+    def GetQueryOptions(self, request: grafana_apiv3_pb2.GetOptionsRequest, context):
+        """List options per query."""
+
+        return grafana_apiv3_pb2.GetOptionsResponse(options=[])
+
+    def ListDimensionKeys(
+        self, request: grafana_apiv3_pb2.ListDimensionKeysRequest, context
+    ):
+        """List available data dimensions."""
+
+        results = [
+            grafana_apiv3_pb2.ListDimensionKeysResponse.Result(
+                key="antenna_field",
+                description="Antenna field",
+            ),
+            grafana_apiv3_pb2.ListDimensionKeysResponse.Result(
+                key="pol",
+                description="Polarisation",
+            ),
+        ]
+        return grafana_apiv3_pb2.ListDimensionKeysResponse(results=results)
+
+    def ListDimensionValues(
+        self, request: grafana_apiv3_pb2.ListDimensionValuesRequest, context
+    ):
+        """List possible values for each data dimension."""
+
+        results = []
+
+        if request.dimension_key == "antenna_field":
+            for antenna_field in self.antenna_fields:
+                results.append(
+                    grafana_apiv3_pb2.ListDimensionValuesResponse.Result(
+                        value=antenna_field,
+                    )
+                )
+
+        if request.dimension_key == "pol":
+            for pol in ["xx", "xy", "yx", "yy"]:
+                results.append(
+                    grafana_apiv3_pb2.ListDimensionValuesResponse.Result(
+                        value=pol,
+                    )
+                )
+
+        return grafana_apiv3_pb2.ListDimensionValuesResponse(results=results)
+
+    def ListMetrics(self, request: grafana_apiv3_pb2.ListMetricsRequest, context):
+        """List available metrics."""
+
+        metrics = [
+            grafana_apiv3_pb2.ListMetricsResponse.Metric(
+                name="BST",
+                description="Beamlet statistics",
+            ),
+            grafana_apiv3_pb2.ListMetricsResponse.Metric(
+                name="SST",
+                description="Subband statistics",
+            ),
+            grafana_apiv3_pb2.ListMetricsResponse.Metric(
+                name="XST",
+                description="Crosslet statistics",
+            ),
+        ]
+
+        return grafana_apiv3_pb2.ListMetricsResponse(Metrics=metrics)
+
+    @call_exception_metrics("GrafanaAPIV3")
+    def GetMetricValue(self, request: grafana_apiv3_pb2.GetMetricValueRequest, context):
+        return grafana_apiv3_pb2.GetMetricValueResponse(frames=[])
+
+    @call_exception_metrics("GrafanaAPIV3")
+    def GetMetricAggregate(
+        self, request: grafana_apiv3_pb2.GetMetricAggregateRequest, context
+    ):
+        """Return the set of values for the request metrics and dimensions."""
+
+        frames = []
+
+        dimensions = {d.key: d.value for d in request.dimensions}
+        time_window = (
+            request.startDate.ToDatetime(tzinfo=timezone.utc),
+            request.endDate.ToDatetime(tzinfo=timezone.utc),
+        )
+
+        for metric in request.metrics:
+            if metric == "BST":
+                frames.extend(
+                    self.bst.all_frames(
+                        time_window,
+                        dimensions["antenna_field"].lower(),
+                        dimensions.get("pol"),
+                    )
+                )
+            elif metric == "SST":
+                frames.extend(
+                    self.sst.all_frames(
+                        time_window,
+                        dimensions["antenna_field"].lower(),
+                        dimensions.get("pol"),
+                    )
+                )
+            elif metric == "XST":
+                frames.extend(
+                    self.xst.all_frames(
+                        time_window,
+                        dimensions["antenna_field"].lower(),
+                        dimensions.get("pol"),
+                    )
+                )
+
+        return grafana_apiv3_pb2.GetMetricAggregateResponse(frames=frames)
diff --git a/tangostationcontrol/tangostationcontrol/rpc/observation.py b/tangostationcontrol/tangostationcontrol/rpc/observation.py
index 62d049574f1bd8985bb150b49d375c6625701dd1..967a58d8b6b6484b7331ddc83f5eee2927e0f6fd 100644
--- a/tangostationcontrol/tangostationcontrol/rpc/observation.py
+++ b/tangostationcontrol/tangostationcontrol/rpc/observation.py
@@ -2,8 +2,8 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import logging
-from lofar_sid.interface.stationcontrol import observation_pb2
-from lofar_sid.interface.stationcontrol import observation_pb2_grpc
+from tangostationcontrol.rpc._proto import observation_pb2
+from tangostationcontrol.rpc._proto import observation_pb2_grpc
 from tangostationcontrol.rpc.common import (
     call_exception_metrics,
     reply_on_exception,
diff --git a/tangostationcontrol/tangostationcontrol/rpc/server.py b/tangostationcontrol/tangostationcontrol/rpc/server.py
index e4dac367a7a8a24904ccc4e387b192c95cad04ef..4130b21e55f365b0369458c12c78dc3b7fb19d1b 100644
--- a/tangostationcontrol/tangostationcontrol/rpc/server.py
+++ b/tangostationcontrol/tangostationcontrol/rpc/server.py
@@ -8,17 +8,19 @@ import sys
 
 import grpc
 from grpc_reflection.v1alpha import reflection
-from lofar_sid.interface.stationcontrol import observation_pb2
-from lofar_sid.interface.stationcontrol import observation_pb2_grpc
-from lofar_sid.interface.stationcontrol import statistics_pb2
-from lofar_sid.interface.stationcontrol import statistics_pb2_grpc
-from lofar_sid.interface.stationcontrol import antennafield_pb2
-from lofar_sid.interface.stationcontrol import antennafield_pb2_grpc
+from tangostationcontrol.rpc._proto import observation_pb2
+from tangostationcontrol.rpc._proto import observation_pb2_grpc
+from tangostationcontrol.rpc._proto import statistics_pb2
+from tangostationcontrol.rpc._proto import statistics_pb2_grpc
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2_grpc
 from tangostationcontrol.rpc.observation import Observation
 from tangostationcontrol.rpc.statistics import Statistics
+from tangostationcontrol.rpc.grafana_api import GrafanaAPIV3
 from tangostationcontrol.rpc.messagehandler import MultiEndpointZMQMessageHandler
 from tangostationcontrol.common.lofar_logging import configure_logger
 from tangostationcontrol.metrics import start_metrics_server
+from tangostationcontrol.rpc._proto import antennafield_pb2, antennafield_pb2_grpc
 from tangostationcontrol.rpc.antennafield import AntennaField
 
 logger = logging.getLogger()
@@ -41,10 +43,14 @@ class Server:
         statistics_pb2_grpc.add_StatisticsServicer_to_server(
             self.statistics_servicer, self.server
         )
+        grafana_apiv3_pb2_grpc.add_GrafanaQueryAPIServicer_to_server(
+            GrafanaAPIV3(self.statistics_servicer, antenna_fields), self.server
+        )
         SERVICE_NAMES = (
             observation_pb2.DESCRIPTOR.services_by_name["Observation"].full_name,
             antennafield_pb2.DESCRIPTOR.services_by_name["Antennafield"].full_name,
             statistics_pb2.DESCRIPTOR.services_by_name["Statistics"].full_name,
+            grafana_apiv3_pb2.DESCRIPTOR.services_by_name["GrafanaQueryAPI"].full_name,
             reflection.SERVICE_NAME,  # reflection is required by innius-gpc-datasource
         )
         reflection.enable_server_reflection(SERVICE_NAMES, self.server)
diff --git a/tangostationcontrol/tangostationcontrol/rpc/statistics.py b/tangostationcontrol/tangostationcontrol/rpc/statistics.py
index 5d46c69a489dd8f0fc832bb2f237916e1914007a..a5e51652a74776d99efeecadd6d1eed6ca7b5bad 100644
--- a/tangostationcontrol/tangostationcontrol/rpc/statistics.py
+++ b/tangostationcontrol/tangostationcontrol/rpc/statistics.py
@@ -3,15 +3,14 @@
 
 from datetime import datetime, timedelta, timezone
 from math import log
-from typing import Dict, Tuple, Callable
+from typing import Dict, Tuple
 import logging
 
 import numpy
 
 from tangostationcontrol.common.constants import N_pol, N_subbands
-from tangostationcontrol.common.frequency_bands import Band, bands
-from lofar_sid.interface.stationcontrol import statistics_pb2
-from lofar_sid.interface.stationcontrol import statistics_pb2_grpc
+from tangostationcontrol.rpc._proto import statistics_pb2
+from tangostationcontrol.rpc._proto import statistics_pb2_grpc
 from tangostationcontrol.rpc.common import (
     call_exception_metrics,
 )
@@ -89,23 +88,6 @@ class Statistics(statistics_pb2_grpc.StatisticsServicer, LastStatisticsMessagesM
             nyquist_zone=message["source_info"]["nyquist_zone_index"],
         )
 
-    @staticmethod
-    def _subband_frequency_func(
-        frequency_band: statistics_pb2.FrequencyBand,
-    ) -> Callable[[int], float]:
-        """Return a function that converts a subband number into its central frequency (in Hz).
-
-        NB: Spectral inversion is assumed to have been configured correctly at time of recording.
-        """
-
-        band_name = Band.lookup_nyquist_zone(
-            frequency_band.antenna_type,
-            frequency_band.clock,
-            frequency_band.nyquist_zone,
-        )
-
-        return bands[band_name].subband_frequency
-
     @call_exception_metrics("Statistics")
     def Bst(self, request: statistics_pb2.BstRequest, context):
         bst_message = self.get_last_message("bst", request.antenna_field)
@@ -137,13 +119,9 @@ class Statistics(statistics_pb2_grpc.StatisticsServicer, LastStatisticsMessagesM
         sst_data = numpy.array(sst_message["sst_data"], dtype=numpy.float32)
         sst_data = sst_data.reshape((-1, N_pol, N_subbands))
 
-        frequency_band = self._message_to_frequency_band(sst_message)
-        subband_frequency = self._subband_frequency_func(frequency_band)
-
         subbands = [
             statistics_pb2.SstResult.SstSubband(
                 subband=subband_nr,
-                frequency=subband_frequency(subband_nr),
                 antennas=[
                     statistics_pb2.SstResult.SstSubband.SstAntenna(
                         antenna=antenna_nr,
@@ -159,7 +137,7 @@ class Statistics(statistics_pb2_grpc.StatisticsServicer, LastStatisticsMessagesM
         return statistics_pb2.SstReply(
             result=statistics_pb2.SstResult(
                 timestamp=datetime.fromisoformat(sst_message["timestamp"]),
-                frequency_band=frequency_band,
+                frequency_band=self._message_to_frequency_band(sst_message),
                 integration_interval=sst_message["integration_interval"],
                 subbands=subbands,
             ),
@@ -209,16 +187,12 @@ class Statistics(statistics_pb2_grpc.StatisticsServicer, LastStatisticsMessagesM
             if antenna1 <= antenna2
         ]
 
-        frequency_band = self._message_to_frequency_band(xst_message)
-        subband_frequency = self._subband_frequency_func(frequency_band)
-
         return statistics_pb2.XstReply(
             result=statistics_pb2.XstResult(
                 timestamp=datetime.fromisoformat(xst_message["timestamp"]),
-                frequency_band=frequency_band,
+                frequency_band=self._message_to_frequency_band(xst_message),
                 integration_interval=xst_message["integration_interval"],
                 subband=xst_message["subband"],
-                frequency=subband_frequency(xst_message["subband"]),
                 baselines=baselines,
             ),
         )
diff --git a/tangostationcontrol/test/rpc/test_antennafield.py b/tangostationcontrol/test/rpc/test_antennafield.py
index 90089d928695f022904cc6bcc2b3f0dfb36e92d1..8bfb89204d1a1bebf7f0b44a63dd60fe65c684ef 100644
--- a/tangostationcontrol/test/rpc/test_antennafield.py
+++ b/tangostationcontrol/test/rpc/test_antennafield.py
@@ -2,7 +2,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 from unittest.mock import MagicMock, patch
-from lofar_sid.interface.stationcontrol.antennafield_pb2 import (
+from tangostationcontrol.rpc._proto.antennafield_pb2 import (
     Identifier,
     SetAntennaStatusRequest,
     SetAntennaUseRequest,
diff --git a/tangostationcontrol/test/rpc/test_grafana_apiv3.py b/tangostationcontrol/test/rpc/test_grafana_apiv3.py
new file mode 100644
index 0000000000000000000000000000000000000000..b57f11ac6b2f99dc3cd68a90a70895b2b59b3409
--- /dev/null
+++ b/tangostationcontrol/test/rpc/test_grafana_apiv3.py
@@ -0,0 +1,369 @@
+# Copyright (C) 2025 ASTRON (Netherlands Institute for Radio Astronomy)
+# SPDX-License-Identifier: Apache-2.0
+
+from datetime import datetime, timezone, timedelta
+import json
+from os import path
+
+from tangostationcontrol.common.constants import (
+    N_subbands,
+    N_pol,
+    N_beamlets_ctrl,
+    N_pn,
+    S_pn,
+    A_pn,
+)
+from tangostationcontrol.rpc.grafana_api import (
+    GrafanaAPIV3,
+    StatisticsToGrafana,
+    BstToGrafana,
+    SstToGrafana,
+    XstToGrafana,
+)
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2
+from tangostationcontrol.rpc.statistics import (
+    Statistics,
+    dB,
+    NotAvailableError,
+    TooOldError,
+)
+from tangostationcontrol.rpc._proto import statistics_pb2
+
+from google.protobuf.timestamp_pb2 import Timestamp
+
+from test import base
+
+
+def field_labels(label_list: list[grafana_apiv3_pb2.Label]) -> dict[str, str]:
+    """Turn a list of objects with key and value fields into a dict."""
+    return {x.key: x.value for x in label_list}
+
+
+class TestStatisticsToGrafana(base.TestCase):
+    def test_verify_call_result(self):
+        statistics = Statistics()
+        sut = StatisticsToGrafana(statistics)
+
+        now = datetime.now(tz=timezone.utc)
+
+        reply = statistics_pb2.SstReply(result=statistics_pb2.SstResult(timestamp=now))
+
+        # should not raise
+        _ = sut._verify_call_result(
+            reply, (now - timedelta(seconds=1), now + timedelta(seconds=1))
+        )
+
+        # too old raises
+        with self.assertRaises(TooOldError):
+            _ = sut._verify_call_result(
+                reply, (now + timedelta(seconds=1), now + timedelta(seconds=2))
+            )
+
+
+class TestBstToGrafana(base.TestCase):
+    def setUp(self):
+        statistics = Statistics()
+        self.sut = BstToGrafana(statistics)
+
+        # provide with sample statistic
+        with open(f"{path.dirname(__file__)}/bst-message.json") as f:
+            self.bst_message = json.load(f)
+            statistics.handle_statistics_message(
+                "bst/lba/cs032", datetime.now(), self.bst_message
+            )
+
+        self.valid_time_window = (
+            datetime.fromisoformat("2025-01-01T00:00+00:00"),
+            datetime.fromisoformat("2026-01-01T00:00+00:00"),
+        )
+
+    def test_all_frames(self):
+        frames = self.sut.all_frames(self.valid_time_window, "lba", None)
+
+        self.assertEqual(1, len(frames))
+        self.assertEqual("BST", frames[0].metric)
+        self.assertEqual(3, len(frames[0].fields))
+        self.assertEqual("beamlet", frames[0].fields[0].name)
+        self.assertEqual("xx", frames[0].fields[1].name)
+        self.assertEqual("yy", frames[0].fields[2].name)
+
+        # all fields must be of the right size
+        self.assertEqual(N_beamlets_ctrl, len(frames[0].timestamps))
+        self.assertEqual(N_beamlets_ctrl, len(frames[0].fields[0].values))
+        self.assertEqual(N_beamlets_ctrl, len(frames[0].fields[1].values))
+        self.assertEqual(N_beamlets_ctrl, len(frames[0].fields[2].values))
+
+        # all timestamps must be equal
+        self.assertEqual(1, len(set([ts.ToDatetime() for ts in frames[0].timestamps])))
+
+        # all beamlets must be there
+        self.assertEqual(set(range(N_beamlets_ctrl)), set(frames[0].fields[0].values))
+
+        # test values
+        for beamlet_idx, value in enumerate(frames[0].fields[1].values):
+            self.assertAlmostEqual(
+                dB(self.bst_message["bst_data"][beamlet_idx][0]),
+                value,
+                5,
+                msg=f"{beamlet_idx=}",
+            )
+        for beamlet_idx, value in enumerate(frames[0].fields[2].values):
+            self.assertAlmostEqual(
+                dB(self.bst_message["bst_data"][beamlet_idx][1]),
+                value,
+                5,
+                msg=f"{beamlet_idx=}",
+            )
+
+
+class TestSstToGrafana(base.TestCase):
+    def setUp(self):
+        statistics = Statistics()
+        self.sut = SstToGrafana(statistics)
+
+        # provide with sample statistic
+        with open(f"{path.dirname(__file__)}/sst-message.json") as f:
+            self.sst_message = json.load(f)
+            statistics.handle_statistics_message(
+                "sst/lba/cs032", datetime.now(), self.sst_message
+            )
+
+        self.valid_time_window = (
+            datetime.fromisoformat("2025-01-01T00:00+00:00"),
+            datetime.fromisoformat("2026-01-01T00:00+00:00"),
+        )
+
+    def test_all_frames(self):
+        frames = self.sut.all_frames(self.valid_time_window, "lba", None)
+
+        self.assertEqual(1, len(frames))
+        self.assertEqual("SST", frames[0].metric)
+        self.assertEqual(1 + N_pn * S_pn, len(frames[0].fields))
+        self.assertEqual("frequency", frames[0].fields[0].name)
+
+        for idx, field in enumerate(frames[0].fields[1:], 1):
+            self.assertEqual("power", field.name, msg=f"{idx=}")
+
+        # all fields must be of the right size
+        self.assertEqual(N_subbands - 1, len(frames[0].timestamps))
+        self.assertEqual(N_subbands - 1, len(frames[0].fields[0].values))
+
+        for idx, field in enumerate(frames[0].fields[1:], 1):
+            self.assertEqual(N_subbands - 1, len(field.values), msg=f"{idx=}")
+
+        # all timestamps must be equal
+        self.assertEqual(1, len(set([ts.ToDatetime() for ts in frames[0].timestamps])))
+
+        # all inputs must be there
+        input_names = {
+            field_labels(field.labels)["antenna"] for field in frames[0].fields[1:]
+        }
+        self.assertEqual(N_pn * A_pn, len(input_names), msg=f"{input_names=}")
+        # both polarisarions must be there
+        input_pols = [
+            field_labels(field.labels)["pol"] for field in frames[0].fields[1:]
+        ]
+        self.assertEqual(N_pn * A_pn, input_pols.count("xx"), msg=f"{input_pols=}")
+        self.assertEqual(N_pn * A_pn, input_pols.count("yy"), msg=f"{input_pols=}")
+
+        # test values
+        for field in frames[0].fields[1:]:
+            labels = field_labels(field.labels)
+
+            antenna_idx = int(labels["antenna"])
+            pol_indices = {"xx": 0, "yy": 1}
+            pol_idx = pol_indices[labels["pol"]]
+            input_idx = antenna_idx * N_pol + pol_idx
+
+            for idx, value in enumerate(field.values):
+                self.assertAlmostEqual(
+                    dB(self.sst_message["sst_data"][input_idx][idx + 1]),
+                    value,
+                    5,
+                    msg=f"{labels=}, {idx=}",
+                )
+
+
+class TestXstToGrafana(base.TestCase):
+    def setUp(self):
+        statistics = Statistics()
+        self.sut = XstToGrafana(statistics)
+
+        # provide with sample statistic
+        with open(f"{path.dirname(__file__)}/xst-message.json") as f:
+            self.xst_message = json.load(f)
+            statistics.handle_statistics_message(
+                "xst/lba/cs032", datetime.now(), self.xst_message
+            )
+
+        self.valid_time_window = (
+            datetime.fromisoformat("2025-01-01T00:00+00:00"),
+            datetime.fromisoformat("2026-01-01T00:00+00:00"),
+        )
+
+    def test_all_frames(self):
+        frames = self.sut.all_frames(self.valid_time_window, "lba", None)
+
+        self.assertEqual(1, len(frames))
+        self.assertEqual("XST", frames[0].metric)
+        self.assertEqual(3 + 2 * N_pol**2, len(frames[0].fields))
+        self.assertEqual("antenna1", frames[0].fields[0].name)
+        self.assertEqual("antenna2", frames[0].fields[1].name)
+        self.assertEqual("frequency", frames[0].fields[2].name)
+        self.assertEqual(
+            {"power", "phase"}, {field.name for field in frames[0].fields[3:]}
+        )
+
+        N_antennas = N_pn * A_pn
+        N_baselines = N_antennas * (N_antennas + 1) // 2
+
+        # all fields must be of the right size
+        self.assertEqual(N_baselines, len(frames[0].timestamps))
+
+        for idx, field in enumerate(frames[0].fields):
+            self.assertEqual(N_baselines, len(field.values), msg=f"{idx=}")
+
+        # all timestamps must be equal
+        self.assertEqual(1, len(set([ts.ToDatetime() for ts in frames[0].timestamps])))
+
+        # all polarisarions must be there (for power and phase)
+        input_pols = [
+            field_labels(field.labels)["pol"] for field in frames[0].fields[3:]
+        ]
+        self.assertEqual(2, input_pols.count("xx"))
+        self.assertEqual(2, input_pols.count("xy"))
+        self.assertEqual(2, input_pols.count("yx"))
+        self.assertEqual(2, input_pols.count("yy"))
+
+        # all antennas must be there
+        self.assertEqual(set(range(N_antennas)), set(frames[0].fields[0].values))
+        self.assertEqual(set(range(N_antennas)), set(frames[0].fields[1].values))
+
+        # test specific value for regression
+        self.assertAlmostEqual(30.7213135, frames[0].fields[3].values[99], 6)
+        self.assertAlmostEqual(3.88297279, frames[0].fields[4].values[99], 6)
+        self.assertAlmostEqual(27.2744141, frames[0].fields[5].values[99], 6)
+        self.assertAlmostEqual(297.7411965, frames[0].fields[6].values[99], 6)
+
+
+class TestGrafanaAPIV3(base.TestCase):
+    def test_getqueryoptions(self):
+        """Test GetQueryOptions."""
+
+        statistics = Statistics()
+        sut = GrafanaAPIV3(statistics, [])
+
+        request = grafana_apiv3_pb2.GetOptionsRequest()
+        reply = sut.GetQueryOptions(request, None)
+
+        self.assertEqual(0, len(reply.options))
+
+    def test_listdimensionkeys(self):
+        """Test ListDimensionKeys."""
+
+        statistics = Statistics()
+        sut = GrafanaAPIV3(statistics, [])
+
+        request = grafana_apiv3_pb2.ListDimensionKeysRequest()
+        reply = sut.ListDimensionKeys(request, None)
+
+        dimensions = {d.key for d in reply.results}
+
+        self.assertIn("antenna_field", dimensions)
+        self.assertIn("pol", dimensions)
+
+    def test_listdimensionvalues(self):
+        """Test ListDimensionValues."""
+
+        statistics = Statistics()
+        sut = GrafanaAPIV3(statistics, ["LBA", "HBA"])
+
+        expected_values = {
+            "antenna_field": {"LBA", "HBA"},
+            "pol": {"xx", "xy", "yx", "yy"},
+        }
+
+        for dim, expected_value in expected_values.items():
+            request = grafana_apiv3_pb2.ListDimensionValuesRequest(dimension_key=dim)
+            reply = sut.ListDimensionValues(request, None)
+
+            value = set([r.value for r in reply.results])
+            self.assertEqual(expected_value, value, msg=f"{dim=}")
+
+    def test_listmetrics(self):
+        """Test ListMetrics."""
+
+        statistics = Statistics()
+        sut = GrafanaAPIV3(statistics, [])
+
+        request = grafana_apiv3_pb2.ListMetricsRequest()
+        reply = sut.ListMetrics(request, None)
+
+        metrics = [m.name for m in reply.Metrics]
+
+        self.assertIn("BST", metrics)
+        self.assertIn("SST", metrics)
+        self.assertIn("XST", metrics)
+
+
+class TestGetMetricAggregate(base.TestCase):
+    def setUp(self):
+        statistics = Statistics()
+        self.sut = GrafanaAPIV3(statistics, ["LBA"])
+
+        # provide with sample statistic
+        with open(f"{path.dirname(__file__)}/sst-message.json") as f:
+            sst_message = json.load(f)
+            statistics.handle_statistics_message(
+                "sst/lba/cs032", datetime.now(), sst_message
+            )
+
+    def test_nometrics(self):
+        """Test if there are no metrics available."""
+
+        statistics = Statistics()
+        sut = GrafanaAPIV3(statistics, ["LBA"])
+
+        # request/response
+        request = grafana_apiv3_pb2.GetMetricAggregateRequest(
+            metrics=["SST"],
+            dimensions=[grafana_apiv3_pb2.Dimension(key="antenna_field", value="LBA")],
+            startDate=Timestamp(seconds=0),
+            endDate=Timestamp(seconds=int(datetime.now().timestamp())),
+        )
+
+        with self.assertRaises(NotAvailableError):
+            _ = sut.GetMetricAggregate(request, None)
+
+    def test_validdaterange(self):
+        """Test obtaining SSTs using a date range for which they are available."""
+
+        # request/response
+        request = grafana_apiv3_pb2.GetMetricAggregateRequest(
+            metrics=["SST"],
+            dimensions=[grafana_apiv3_pb2.Dimension(key="antenna_field", value="LBA")],
+            startDate=Timestamp(seconds=0),
+            endDate=Timestamp(seconds=int(datetime.now().timestamp())),
+        )
+
+        reply = self.sut.GetMetricAggregate(request, None)
+
+        # validate output
+        self.assertEqual(1, len(reply.frames))
+        self.assertEqual("SST", reply.frames[0].metric)
+
+    def test_tooold(self):
+        """Test obtaining SSTs using a date range for which they are too old."""
+
+        # request/response
+        request = grafana_apiv3_pb2.GetMetricAggregateRequest(
+            metrics=["SST"],
+            dimensions=[grafana_apiv3_pb2.Dimension(key="antenna_field", value="LBA")],
+            startDate=Timestamp(seconds=int(datetime.now().timestamp()) - 1),
+            endDate=Timestamp(seconds=int(datetime.now().timestamp())),
+        )
+
+        reply = self.sut.GetMetricAggregate(request, None)
+
+        # validate output
+        self.assertEqual(0, len(reply.frames))
diff --git a/tangostationcontrol/test/rpc/test_observation.py b/tangostationcontrol/test/rpc/test_observation.py
index 9d6a3915c7ec3173bdb15d8b1447176d194e4411..2da9fba52c5818c5f7731cb58a2d0b31f0c3d665 100644
--- a/tangostationcontrol/test/rpc/test_observation.py
+++ b/tangostationcontrol/test/rpc/test_observation.py
@@ -4,7 +4,7 @@
 from unittest import mock
 
 from tangostationcontrol.rpc.observation import Observation
-from lofar_sid.interface.stationcontrol import observation_pb2
+from tangostationcontrol.rpc._proto import observation_pb2
 
 from test import base
 
diff --git a/tangostationcontrol/test/rpc/test_server.py b/tangostationcontrol/test/rpc/test_server.py
index d7bbc4c88467e42e7c2c531557dddd8d385d38cf..73bda154167c53fb83bdbed76a7ee7de49ddb26f 100644
--- a/tangostationcontrol/test/rpc/test_server.py
+++ b/tangostationcontrol/test/rpc/test_server.py
@@ -8,8 +8,8 @@ from grpc_reflection.v1alpha.proto_reflection_descriptor_database import (
     ProtoReflectionDescriptorDatabase,
 )
 
-from lofar_sid.interface.stationcontrol import antennafield_pb2
-from lofar_sid.interface.stationcontrol import antennafield_pb2_grpc
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2
+from tangostationcontrol.rpc._proto import grafana_apiv3_pb2_grpc
 from tangostationcontrol.rpc.server import Server
 
 from test import base
@@ -36,17 +36,11 @@ class TestServer(base.TestCase):
             self.assertIn("Observation", services)
             self.assertIn("Antennafield", services)
             self.assertIn("Statistics", services)
+            self.assertIn("grafanav3.GrafanaQueryAPI", services)
 
     def test_call(self):
         """Test a basic gRPC call to the server."""
 
         with grpc.insecure_channel(f"localhost:{self.server.port}") as channel:
-            stub = antennafield_pb2_grpc.AntennafieldStub(channel)
-
-            identifier = antennafield_pb2.Identifier(
-                antennafield_name="lba",
-                antenna_name="LBA00",
-            )
-            _ = stub.GetAntenna(
-                antennafield_pb2.GetAntennaRequest(identifier=identifier)
-            )
+            stub = grafana_apiv3_pb2_grpc.GrafanaQueryAPIStub(channel)
+            _ = stub.GetQueryOptions(grafana_apiv3_pb2.GetOptionsRequest())
diff --git a/tangostationcontrol/test/rpc/test_statistics.py b/tangostationcontrol/test/rpc/test_statistics.py
index 3b9f90373b93cb89d604e124d99ab8c6d88d1b1e..141aebb52f1e454f59bf91f94640059c74dc9457 100644
--- a/tangostationcontrol/test/rpc/test_statistics.py
+++ b/tangostationcontrol/test/rpc/test_statistics.py
@@ -20,7 +20,7 @@ from tangostationcontrol.rpc.statistics import (
     TooOldError,
     NotAvailableError,
 )
-from lofar_sid.interface.stationcontrol import statistics_pb2
+from tangostationcontrol.rpc._proto import statistics_pb2
 
 from test import base
 
diff --git a/tangostationcontrol/tox.ini b/tangostationcontrol/tox.ini
index 4e414a875eddc076380c180b5b364e7554ffd785..090286a2a5a32c2a804d3a619cd93ff2ee52274e 100644
--- a/tangostationcontrol/tox.ini
+++ b/tangostationcontrol/tox.ini
@@ -29,6 +29,10 @@ allowlist_externals =
 commands_pre =
     {envpython} --version
     {work_dir}/.tox/bin/python -m tox --version
+    {envpython} -m grpc_tools.protoc -Itangostationcontrol/rpc/_proto=proto --python_out=. --pyi_out=. --grpc_python_out=. proto/observation.proto
+    {envpython} -m grpc_tools.protoc -Itangostationcontrol/rpc/_proto=proto --python_out=. --pyi_out=. --grpc_python_out=. proto/antennafield.proto
+    {envpython} -m grpc_tools.protoc -Itangostationcontrol/rpc/_proto=proto --python_out=. --pyi_out=. --grpc_python_out=. proto/statistics.proto
+    {envpython} -m grpc_tools.protoc -Itangostationcontrol/rpc/_proto=proto --python_out=. --pyi_out=. --grpc_python_out=. proto/grafana-apiv3.proto
 commands =
     {envpython} -m pytest --version
     {envpython} -m pytest -v --log-level=DEBUG --forked test/{posargs}
@@ -120,4 +124,4 @@ commands =
 [flake8]
 filename = *.py,.stestr.conf,.txt
 ignore = B014, B019, B028, W291, W293, W391, E111, E114, E121, E122, E123, E124, E126, E127, E128, E131, E201, E201, E202, E203, E221, E222, E225, E226, E231, E241, E251, E252, E261, E262, E265, E271, E301, E302, E303, E305, E306, E401, E402, E501, E502, E701, E712, E721, E731, F403, F523, F541, F841, H301, H306, H401, H403, H404, H405, W503
-exclude = SNMP_mib_loading,output_pymibs
+exclude = SNMP_mib_loading,output_pymibs,_proto