Skip to content

Instantly share code, notes, and snippets.

@ChadiEM
Last active October 12, 2023 07:49
Show Gist options
  • Save ChadiEM/bc4014fa61f08b31f3d42a5e78c49d9b to your computer and use it in GitHub Desktop.
Save ChadiEM/bc4014fa61f08b31f3d42a5e78c49d9b to your computer and use it in GitHub Desktop.

Preview

Preview

Installation

smashing install bc4014fa61f08b31f3d42a5e78c49d9b

Configuration

  • ratp.scss: adjust background-color and font-size to your preference.
  • ratp.rb: configure with the desired stops and directions.

Pro-tip: while you can still set the desired transports in ratp.rb, it is a better idea to separate the code from the data by placing the data in the config/settings.rb file instead. This will make the upgrades easier.

To do so, the config directory should be created at the root level of the dashboard (at the same level of the jobs folder), and inside it, you should create a file called settings.rb.

The settings.rb file is automatically picked up before the widget.

In that file, you would define your transports:

require_relative '../jobs/ratp_utils.rb'

TRANSPORTS = [
  Transport.new(Type::BUS, '22', 'Ranelagh', 'Gare Saint-Lazare'),
  Transport.new(Type::METRO, '9', 'Ranelagh', 'Mairie de Montreuil'),
  Transport.new(Type::TRAM, '2', 'Porte de Versailles', 'Pont de Bezons'),
]

Some destinations are incorrect in the IDFM database. You can use 'A' or 'R' as destinations.

Transport.new(Type::BUS, '42', 'Versailles - Chardon Lagache', 'R')

Usage

<li data-row="1" data-col="1" data-sizex="1" data-sizey="1">
  <div data-id="ratp" data-view="Ratp" data-title="RATP"></div>
</li>

You can omit data-title to free up space for more destinations.

class Dashing.Ratp extends Dashing.Widget
onData: (data) ->
for result in data.results
transportId = result.key.replace(/[^a-zA-Z0-9\-]+/g, '')
currentResult = result.value
transportId1 = transportId + '-1'
transportId2 = transportId + '-2'
element = $("##{transportId}-1").val()
if not element?
# First time, build the table
@createRow(currentResult.type, currentResult.id, transportId1)
.insertBefore($('.widget-ratp #placeholder'))
@createRow(currentResult.type, currentResult.id, transportId2)
.insertBefore($('.widget-ratp #placeholder'))
@update(transportId1, currentResult.d1, currentResult.t1, currentResult.status)
@update(transportId2, currentResult.d2, currentResult.t2, currentResult.status)
createRow: (type, id, transportId) ->
cellIcon = $ '<span>'
cellIcon.addClass 'transport'
cellIcon.attr 'id', transportId + '-icon'
imgIcon = $ '<img>'
imgIcon.attr 'src', "https://www.ratp.fr/sites/default/files/network/#{type}/ligne#{id}.svg"
imgIcon.addClass type
imgIcon.addClass 'icon'
imgIcon.on 'error', ->
console.log "Unable to retrieve #{imgIcon.attr 'src'}"
cellIcon.html id # If image is not available, fall back to text
cellIcon.append imgIcon
cellDest = $ '<span>'
cellDest.addClass 'dest'
cellDest.attr 'id', transportId + '-dest'
cellTime = $ '<span>'
cellTime.addClass 'time'
cellTime.attr 'id', transportId + '-time'
row = $ '<div>'
row.attr 'id', transportId
row.addClass 'item'
row.append cellIcon
row.append cellDest
row.append cellTime
return row
update: (id, newDest, newTime, status) ->
@iconUpdate(id, status)
@fadeUpdate(id, 'dest', newDest)
@fadeUpdate(id, 'time', newTime)
iconUpdate: (id, status) ->
iconId = "##{id}-icon"
if status == 'critical'
$(iconId).addClass 'critical'
else
$(iconId).removeClass 'critical'
if status == 'alerte'
$(iconId).addClass 'alert'
else
$(iconId).removeClass 'alert'
fadeUpdate: (id, type, newValue) ->
spanId = "##{id}-#{type}"
if newValue == '[ND]'
$(spanId).addClass 'grayed'
else
$(spanId).removeClass 'grayed'
oldValue = $(spanId).html()
if oldValue != newValue
$(spanId).fadeOut(->
if type == 'time'
if !(newValue.includes ' mn') && !(newValue.match /\d{1,2}:\d{1,2}/)
$(spanId).addClass 'condensed'
else
$(spanId).removeClass 'condensed'
$(this).html(newValue).fadeIn()
)
<div id="ratpContainer">
<h1 data-bind="title" data-showif="title"></h1>
<!-- Elements will be dynamically added -->
<span id="placeholder"></span>
<p id="ratp-updated-at" class="updated-at" data-bind="updatedAtMessage"></p>
</div>
# frozen_string_literal: true
require_relative 'ratp_utils'
# Uncomment and define transports and apiKey below
# (or alternatively, define them in config/settings.rb)
# API Key can be obtained from https://prim.iledefrance-mobilites.fr/fr/mon-jeton-api (need account)
# TRANSPORTS = [
# Transport.new(Type::BUS, '22', 'Ranelagh', 'Opera'),
# Transport.new(Type::BUS, '52', 'Ranelagh', 'Opera'),
# Transport.new(Type::METRO, '9', 'Ranelagh', 'Mairie de Montreuil'),
# Transport.new(Type::METRO, '9', 'Ranelagh', 'Pont de Sevres')
# ]
# RATP_UPDATE_INTERVAL = '10s'
# IDFM_API_KEY = 'apiKey'
# Do not modify beyond this point.
# TODO: error handling (stop not found, terminus wrong)
# TODO: a l'approche
raise('WARN: RATP: IDFM_API_KEY not defined. Please create an API key and try again.') unless defined? IDFM_API_KEY
unless defined? TRANSPORTS
TRANSPORTS = [].freeze
puts('WARN: RATP: Transports not defined. See README for more info!')
end
RATP_UPDATE_INTERVAL = '10s' unless defined? RATP_UPDATE_INTERVAL
lines = TRANSPORTS.map do |t|
Line.new(t.type, t.number)
end.uniq
line_ids, pictos = get_line_details(lines)
lines_by_id = line_ids.invert
pairs = []
TRANSPORTS.each do |t|
line_id = line_ids[Line.new(t.type, t.number)]
stop_name = t.stop
pairs.push(StopAtRoute.new(Line.new(t.type, t.number), line_id, stop_name))
end
stop_ids, line_and_stop_name_map = get_stop_ids(pairs)
requests = {}
TRANSPORTS.each do |t|
line_id = line_ids[Line.new(t.type, t.number)]
cur_stop_ids = line_and_stop_name_map[line_id][t.stop]
cur_stop_ids.each do |stop_id|
requests[t] = [] if requests[t].nil?
requests[t].push(Request.new(line_id, stop_id, t.destination))
end
end
SCHEDULER.every RATP_UPDATE_INTERVAL, first_in: 0 do |job|
all_timings = get_all_timings(stop_ids)
all_data = find_results(all_timings, requests)
results = []
keys = all_data.keys
keys.each do |k|
val = all_data[k]
line = lines_by_id[val[0][:route]]
results.push(
key: k,
value: {
type: line[:type][:api],
id: line[:id],
picto: pictos[val[0][:route]],
d1: val[0][:dest],
t1: val[0][:time],
d2: val[1].nil? ? '' : val[1][:dest],
t2: val[1].nil? ? '' : val[1][:time]
}
)
end
send_event('ratp', results: results)
rescue ConfigurationError => e
warn("ERROR: RATP: #{e}")
job.unschedule
end
// ----------------------------------------------------------------------------
// Sass declarations
// ----------------------------------------------------------------------------
$background-color: #607d8b;
$font-size: 0.85em;
$rounded-width: 1.72em;
$rounded-height: 1.3em;
$rect-width: 1.72em;
$rect-height: 1em;
// ----------------------------------------------------------------------------
// Widget-ratp styles
// ----------------------------------------------------------------------------
.widget-ratp {
font-size: $font-size;
background-color: $background-color;
display: flex !important;
h1 {
margin-top: 12px;
}
.alert {
background-color: rgba(255, 165, 0, 0.4);
}
.critical {
background-color: rgba(255, 0, 0, 0.3);
}
.bus,
.noctilien {
width: $rect-width;
height: $rect-height;
}
.metro,
.tram,
.rer {
width: $rounded-width;
height: $rounded-height;
}
.dest {
padding-left: 0.2em;
padding-right: 0.5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
flex: 1 1 auto;
}
.time {
white-space: nowrap;
}
.grayed {
color: #a2a2a2;
}
.condensed {
font-size: 0.8em;
}
#ratpContainer {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
}
.item {
padding-left: 0.2em;
padding-right: 0.2em;
display: flex;
align-items: center;
flex: 1 1 auto;
border: 1px solid rgba(0, 0, 0, 0.125);
flex-direction: row;
justify-content: space-between;
}
.updated-at {
position: relative;
margin-top: 10px;
margin-bottom: 10px;
bottom: 0;
}
}
# frozen_string_literal: true
require 'net/http'
require 'json'
require 'csv'
require 'logger'
LOG = Logger.new($stdout, progname: 'ratp')
NA_UI = '[ND]'
Transport = Struct.new(:type, :number, :stop, :destination) do
def to_s
"#{type[:ui]} #{number} @ #{stop} -> #{destination}"
end
end
Line = Struct.new(:type, :id) do
def to_s
"#{type[:ui]} #{id}"
end
end
StopAtRoute = Struct.new(:line, :route_id, :stop_name)
Request = Struct.new(:route_id, :stop_id, :direction)
class Type
METRO = { ui: 'metro', api: 'metro' }.freeze
BUS = { ui: 'bus', api: 'bus' }.freeze
RER = { ui: 'rer', api: 'rail' }.freeze
TRAM = { ui: 'tram', api: 'tram' }.freeze
end
class ConfigurationError < StandardError
end
def read_csv(url)
uri = URI(url)
response = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
request = Net::HTTP::Get.new(uri)
response = http.request(request)
end
return nil unless response.is_a? Net::HTTPSuccess
csv_data = CSV.new(response.body, headers: true, col_sep: ';', encoding: 'utf-8')
while (row = csv_data.shift)
yield row
end
end
def get_line_details(lines)
LOG.info('Reading line details')
ids = {}
pictos = {}
url = 'https://data.iledefrance-mobilites.fr/explore/dataset/referentiel-des-lignes/download/?format=csv&timezone=Europe/Paris&lang=fr&use_labels_for_header=true&csv_separator=%3B'
read_csv(url) do |row|
# TODO: check if RATP can be taken as input
lines.each do |line|
next unless row['TransportMode'] == line.type[:api] && row['ShortName_Line'] == line.id && (
row['OperatorName'] == 'RATP' || row['OperatorName'] == 'SNCF'
)
ids[line] = row['ID_Line']
pictos[row['ID_Line']] = row['Picto']
end
end
lines.each do |line|
raise ConfigurationError, "Cannot find line #{line}" if ids[line].nil?
end
LOG.info('End line details')
[ids, pictos]
end
def get_stop_ids(route_id_stop_name_pairs)
LOG.info('Reading stop ids')
stop_ids = []
line_and_stop_name_map = {}
stops_at_route = {}
url = 'https://data.iledefrance-mobilites.fr/explore/dataset/arrets-lignes/download/?format=csv&timezone=Europe/Berlin&lang=fr&use_labels_for_header=true&csv_separator=%3B'
read_csv(url) do |row|
route_id_stop_name_pairs.each do |pair|
if row['route_id'] == "IDFM:#{pair.route_id}"
stops_at_route[pair.route_id] = [] if stops_at_route[pair.route_id].nil?
stops_at_route[pair.route_id].push(row['stop_name'])
end
next unless row['route_id'] == "IDFM:#{pair.route_id}" && row['stop_name'] == pair.stop_name
cur_stop_id = row['stop_id'].delete('^0-9')
stop_ids.push(cur_stop_id)
line_and_stop_name_map[pair.route_id] = {} if line_and_stop_name_map[pair.route_id].nil?
if line_and_stop_name_map[pair.route_id][pair.stop_name].nil?
line_and_stop_name_map[pair.route_id][pair.stop_name] = []
end
line_and_stop_name_map[pair.route_id][pair.stop_name].push(cur_stop_id)
end
end
route_id_stop_name_pairs.each do |pair|
if line_and_stop_name_map[pair.route_id].nil? || line_and_stop_name_map[pair.route_id][pair.stop_name].nil?
raise ConfigurationError,
"Unable to find stop #{pair.stop_name} for #{pair.line}. Possible values: #{stops_at_route[pair.route_id]}"
end
end
LOG.info('End stop ids')
[stop_ids.uniq, line_and_stop_name_map]
end
def request_info(stop_id)
uri = URI("https://prim.iledefrance-mobilites.fr/marketplace/stop-monitoring?MonitoringRef=STIF:StopPoint:Q:#{stop_id}:")
response = nil
Net::HTTP.start(uri.host, uri.port, { use_ssl: true, read_timeout: 5, open_timeout: 5 }) do |http|
request = Net::HTTP::Get.new(uri, {
accept: 'application/json',
apiKey: IDFM_API_KEY
})
response = http.request(request)
end
json = JSON.parse(response.body)
json['Siri']['ServiceDelivery']['StopMonitoringDelivery'][0]['MonitoredStopVisit']
end
def get_all_timings(stop_ids)
all_timings = []
semaphore = Mutex.new
get_update_data = lambda { |stop_id|
begin
useful_data = request_info(stop_id)
semaphore.synchronize do
all_timings.push(*useful_data)
end
rescue StandardError => e
warn("ERROR: RATP: Unable to read timings for #{stop_id}: #{e}")
end
}
threads = []
stop_ids.each do |stop_id|
task = Thread.new(stop_id) do |this|
get_update_data.call(this)
end
threads << task
end
threads.map(&:join)
all_timings
end
def find_results(entries, requests)
results_per_key = {}
requests.each_key do |transport|
found = false
requests[transport].each do |queried_item|
key = "#{queried_item.route_id}-#{queried_item.stop_id}-#{queried_item.direction}"
entries.each do |item|
id = item['ItemIdentifier']
dir = id.split('.')[2]
dir = id.split('.')[6] unless %w[A R].include?(dir)
dir_name = item['MonitoredVehicleJourney']['DirectionName'][0]['value'].downcase.delete('^a-z')
route_id = item['MonitoredVehicleJourney']['LineRef']['value'].delete('STIF:Line::').delete_suffix(':')
stop_id = item['MonitoringRef']['value'].delete('^0-9')
unless queried_item.route_id == route_id && queried_item.stop_id == stop_id &&
(queried_item.direction == dir || queried_item.direction.downcase.delete('^a-z') == dir_name)
next
end
dest_name = item['MonitoredVehicleJourney']['DestinationName'][0]['value']
at_stop = item['MonitoredVehicleJourney']['MonitoredCall']['VehicleAtStop']
if at_stop
remaining_time = 'Arrêt'
else
now = DateTime.now
upcoming = DateTime.parse(item['MonitoredVehicleJourney']['MonitoredCall']['ExpectedDepartureTime'])
remaining = [0, ((upcoming.to_time - now.to_time) / 60).round(half: :down)].max
remaining_time = "#{remaining} mn"
end
results_per_key[key] = [] if results_per_key[key].nil?
results_per_key[key].append(
{
route: route_id,
dest: dest_name,
time: remaining_time
}
)
found = true
end
end
next if found
# TODO: fix key to be route id
results_per_key[transport.to_s] = []
results_per_key[transport.to_s].append(
{
route: requests[transport][0].route_id,
dest: '',
time: 'N/D'
}
)
results_per_key[transport.to_s].append(
{
route: requests[transport][0].route_id,
dest: '',
time: 'N/D'
}
)
end
results_per_key
end
@ChadiEM
Copy link
Author

ChadiEM commented Oct 26, 2022

Bonjour, effectivement il y'a un problème sur l'API que j'utilise pour récupérer les données. L'auteur de cette API nous a promis de regarder mais rien encore est fait.

Pendant ce temps, je suis en train de bouger le code vers l'API d'IDFM, mais cette API est mal documentée et manque des informations.

Prochain update d'ici une semaine :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment