diff --git a/Project.toml b/Project.toml
index f13530a0c3efffc1c4e5401e4ce5805a7d101843..47ba923d8ff6044f3684b15d4c286f3ff604a0ef 100644
--- a/Project.toml
+++ b/Project.toml
@@ -27,7 +27,7 @@ KM3ioKM3DBExt = "KM3DB"
 DocStringExtensions = "0.8, 0.9"
 HDF5 = "^0.16.15, ^0.17"
 KM3DB = "0.2.3"
-KM3NeTTestData = "^0.4.16"
+KM3NeTTestData = "^0.4.17"
 StaticArrays = "1"
 UnROOT = "^0.10.26"
 julia = "1"
diff --git a/src/exports.jl b/src/exports.jl
index bad67b989490e2209a361e5635418e24cabdb689..07cfd71b2449f529f81d5c7f76d4010912e0c445 100644
--- a/src/exports.jl
+++ b/src/exports.jl
@@ -22,6 +22,7 @@ PMT,
 StringMechanics,
 StringMechanicsParameters,
 Tripod,
+PMTFile,
 center,
 getmodule,
 getpmt,
diff --git a/src/hardware.jl b/src/hardware.jl
index 22f89e97655a31003b104796dac08f62c1ead5ae..970f9715c8ca1f47fe11a3e2c5200439f2be3f91 100644
--- a/src/hardware.jl
+++ b/src/hardware.jl
@@ -740,3 +740,84 @@ function read(filename::AbstractString, T::Type{StringMechanics})
     end
     T(StringMechanicsParameters(default_a, default_b), stringparams)
 end
+
+struct PMTParameters
+    QE::Float64  # probability of underamplified hit
+    PunderAmplified::Float64  # probability of underamplified hit
+    TTS_ns::Float64  # transition time spread [ns]
+    gain::Float64  # [unit]
+    gainSpread::Float64  # [unit]
+    mean_ns::Float64  # mean time-over-threshold of threshold-band hits [ns]
+    riseTime_ns::Float64  # rise time of analogue pulse [ns]
+    saturation::Float64  # [ns]
+    sigma_ns::Float64 # time-over-threshold standard deviation of threshold-band hits [ns]
+    slewing::Bool # time slewing of analogue signal
+    slope::Float64  # [ns/npe]
+    threshold::Float64  # [npe]
+    thresholdBand::Float64  # [npe]
+end
+Base.isvalid(p::PMTParameters) = !(p.QE < 0 || p.gain < 0 || p.gainSpread < 0 || p.threshold < 0 || p.thresholdBand < 0)
+
+struct PMTData
+    QE::Float64
+    gain::Float64
+    gainSpread::Float64
+    riseTime_ns::Float64
+    TTS_ns::Float64
+    threshold::Float64
+end
+
+struct PMTFile
+    QE::Float64  # relative quantum efficiency
+    mu::Float64
+    comments::Vector{String}
+    parameters::PMTParameters
+    pmt_data::Dict{Tuple{Int, Int}, PMTData}
+end
+function Base.show(io::IO, p::PMTFile)
+    print(io, "PMTFile containing parameters of $(length(p.pmt_data)) PMTs")
+end
+Base.getindex(p::PMTFile, dom_id::Integer, channel_id::Integer) = p.pmt_data[dom_id, channel_id]
+
+"""
+
+Read PMT parameters from a K40 calibration output file.
+
+"""
+function read(filename::AbstractString, T::Type{PMTFile})
+    pmt_data = Dict{Tuple{Int, Int}, PMTData}()
+    fobj = open(filename, "r")
+    comments, content = _split_comments(readlines(fobj), "#")
+    close(fobj)
+
+    QE=0
+    mu=0
+    raw_pmt_parameters = Dict{Symbol, Float64}()
+    pmt_data = Dict{Tuple{Int, Int}, PMTData}()
+    for line in content
+        startswith(line, "#") && continue
+        if startswith(line, "QE=")
+            QE = parse(Float64, split(line, "=")[2])
+            continue
+        end
+        if startswith(line, "mu")
+            mu = parse(Float64, split(line, "=")[2])
+            continue
+        end
+        m = match(r"%\.(.+)=(.+)", line)
+        if !isnothing(m)
+            raw_pmt_parameters[Symbol(m[1])] = parse(Float64, m[2])
+            continue
+        end
+        if startswith(line, "PMT=")
+            sline = split(line)
+            dom_id = parse(Int, sline[2])
+            channel_id = parse(Int, sline[3])
+            pmt_data[(dom_id, channel_id)] = PMTData([parse(t, v) for (t, v) in zip(fieldtypes(PMTData), sline[4:9])]...)
+            continue
+        end
+    end
+
+    pmt_parameters = PMTParameters([raw_pmt_parameters[f] for f in fieldnames(PMTParameters)]...)
+    PMTFile(QE, mu, comments, pmt_parameters, pmt_data)
+end
diff --git a/test/hardware.jl b/test/hardware.jl
index 1179572f307f6ca586bcf236f348f0beb4dfcbbc..03394c687faefc8304333d868af14b8112816a09 100644
--- a/test/hardware.jl
+++ b/test/hardware.jl
@@ -217,3 +217,29 @@ end
     d = Detector(133)
     @test 399 == length(d)
 end
+
+
+@testset "PMTFile" begin
+    p = read(datapath("pmt", "calibration_00000117_H_1.0.0_00013757_00013826_1.txt"), PMTFile)
+    @test length(p.pmt_data) == 7254
+    @test isvalid(p.parameters)
+    @test 0.808 ≈ p[806451572, 4].QE
+    @test 0.950 ≈ p[806451572, 4].gain
+    @test 0.521 ≈ p[806451572, 4].gainSpread
+    @test 7.24 ≈ p[806451572, 4].riseTime_ns
+    @test -1.00 ≈ p[806451572, 4].TTS_ns
+    @test 0.240 ≈ p[806451572, 4].threshold
+    @test 0.05 ≈ p.parameters.PunderAmplified
+    @test 1 ≈ p.parameters.QE
+    @test -1 ≈ p.parameters.TTS_ns
+    @test 1 ≈ p.parameters.gain
+    @test 0.4 ≈ p.parameters.gainSpread
+    @test 4.5 ≈ p.parameters.mean_ns
+    @test 7.24 ≈ p.parameters.riseTime_ns
+    @test 210 ≈ p.parameters.saturation
+    @test 1.5 ≈ p.parameters.sigma_ns
+    @test p.parameters.slewing
+    @test 7 ≈ p.parameters.slope
+    @test 0.24≈ p.parameters.threshold
+    @test 0.12≈ p.parameters.thresholdBand
+end